From 85e08712ec84db02ae6e5cd892998aff5916bacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 26 May 2025 11:24:24 +0100 Subject: [PATCH 01/12] Fix loading transition --- .../compose/ui/channel/info/ChannelInfoMemberInfoModalSheet.kt | 3 +-- .../android/compose/ui/channel/info/DirectChannelInfoScreen.kt | 2 +- .../android/compose/ui/channel/info/GroupChannelInfoScreen.kt | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) 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( From 8a1ab9e9bc14a3798ef04d95170ce3ac72e0d59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 27 May 2025 09:58:06 +0100 Subject: [PATCH 02/12] Clarify that adaptive layout is experimental --- .../src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..c3dc45472dd 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 From 3ccae131f7ebc0f44267dcf5b75bac984362b8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 26 May 2025 14:16:48 +0100 Subject: [PATCH 03/12] Navigate to distinct channel --- .../ui/channel/GroupChannelInfoActivity.kt | 40 ++++++++-- .../compose/sample/ui/chats/ChatsActivity.kt | 47 +++++++++--- .../api/stream-chat-android-ui-common.api | 32 +++++++- .../info/ChannelInfoMemberViewController.kt | 52 +++++++++++-- .../info/ChannelInfoMemberViewEvent.kt | 5 +- .../channel/info/ChannelInfoViewController.kt | 9 ++- .../channel/info/ChannelInfoViewEvent.kt | 14 ++++ .../ChannelInfoMemberViewControllerTest.kt | 75 ++++++++++++++++--- 8 files changed, 235 insertions(+), 39 deletions(-) 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..1e7287d5796 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.add.AddChannelActivity 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 @@ -78,13 +80,8 @@ class GroupChannelInfoActivity : BaseConnectedActivity() { is ChannelInfoViewEvent.Error -> showError(event) - is ChannelInfoViewEvent.NavigateUp -> { - setResult(RESULT_OK) - finish() - } - - is ChannelInfoViewEvent.NavigateToPinnedMessages -> - openPinnedMessages() + is ChannelInfoViewEvent.Navigation -> + handleNavigationEvent(event) else -> Unit } @@ -93,6 +90,35 @@ class GroupChannelInfoActivity : BaseConnectedActivity() { } } + private fun handleNavigationEvent(event: ChannelInfoViewEvent.Navigation) { + when (event) { + is ChannelInfoViewEvent.NavigateUp -> { + setResult(RESULT_OK) + finish() + } + + is ChannelInfoViewEvent.NavigateToPinnedMessages -> + openPinnedMessages() + + is ChannelInfoViewEvent.NavigateToChannel -> { + val intent = MessagesActivity.createIntent( + context = this, + channelId = event.channelId, + ) + startActivity(intent) + } + + is ChannelInfoViewEvent.NavigateToNewChannel -> { + // TODO NavigateToNewChannel + // val intent = AddChannelActivity.createIntent( + // context = this, + // memberId = event.memberId, + // ) + startActivity(Intent(this, AddChannelActivity::class.java)) + } + } + } + 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..a6432618565 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 @@ -259,6 +259,13 @@ class ChatsActivity : BaseConnectedActivity() { onNavigationIconClick = { navigator.navigateBack() }, onNavigateUp = { navigator.popUpTo(pane = ThreePaneRole.List) }, onNavigateToPinnedMessages = { navigator.navigateToPinnedMessages(mode.channelId) }, + onNavigateToChannel = { channelId -> + navigator.navigateToChannel( + channelId = channelId, + replace = !singlePane, + popUp = !singlePane, + ) + }, ) is InfoContentMode.PinnedMessages -> PinnedMessagesContent( @@ -319,11 +326,12 @@ class ChatsActivity : BaseConnectedActivity() { onNavigationIconClick: () -> Unit, onNavigateUp: () -> Unit, onNavigateToPinnedMessages: () -> Unit, + onNavigateToChannel: (channelId: String) -> Unit, ) { val viewModelFactory = ChannelInfoViewModelFactory(context = applicationContext, cid = channelId) val viewModel = viewModel(key = channelId, factory = viewModelFactory) - viewModel.handleChannelInfoEvents(onNavigateUp, onNavigateToPinnedMessages) + viewModel.handleChannelInfoEvents(onNavigateUp, onNavigateToPinnedMessages, onNavigateToChannel) if (AdaptiveLayoutInfo.singlePaneWindow()) { GroupChannelInfoScreen( @@ -350,12 +358,14 @@ class ChatsActivity : BaseConnectedActivity() { private fun ChannelInfoViewModel.handleChannelInfoEvents( onNavigateUp: () -> Unit, onNavigateToPinnedMessages: () -> Unit, + onNavigateToChannel: (channelId: String) -> Unit = {}, ) { LaunchedEffect(this) { events.collectLatest { event -> when (event) { is ChannelInfoViewEvent.NavigateUp -> onNavigateUp() is ChannelInfoViewEvent.NavigateToPinnedMessages -> onNavigateToPinnedMessages() + is ChannelInfoViewEvent.NavigateToChannel -> onNavigateToChannel(event.channelId) is ChannelInfoViewEvent.Error -> showError(event) else -> Unit } @@ -479,33 +489,52 @@ private fun ThreePaneNavigator.navigateToMessage( ) } +private fun ThreePaneNavigator.navigateToChannel( + channelId: String, + replace: Boolean, + popUp: Boolean, +) { + navigateTo( + destination = ThreePaneDestination( + pane = ThreePaneRole.Detail, + arguments = ChatMessageSelection(channelId), + ), + replace = replace, + popUpTo = if (popUp) { + ThreePaneRole.Detail + } else { + null + }, + ) +} + private fun Context.showError(error: ChannelInfoViewEvent.Error) { val message = when (error) { ChannelInfoViewEvent.RenameChannelError, - -> R.string.stream_ui_channel_info_rename_group_error + -> R.string.stream_ui_channel_info_rename_group_error ChannelInfoViewEvent.MuteChannelError, ChannelInfoViewEvent.UnmuteChannelError, - -> R.string.stream_ui_channel_info_mute_conversation_error + -> R.string.stream_ui_channel_info_mute_conversation_error ChannelInfoViewEvent.HideChannelError, ChannelInfoViewEvent.UnhideChannelError, - -> R.string.stream_ui_channel_info_hide_conversation_error + -> R.string.stream_ui_channel_info_hide_conversation_error ChannelInfoViewEvent.LeaveChannelError, - -> R.string.stream_ui_channel_info_leave_conversation_error + -> R.string.stream_ui_channel_info_leave_conversation_error ChannelInfoViewEvent.DeleteChannelError, - -> R.string.stream_ui_channel_info_delete_conversation_error + -> R.string.stream_ui_channel_info_delete_conversation_error ChannelInfoViewEvent.BanMemberError, - -> R.string.stream_ui_channel_info_ban_member_error + -> R.string.stream_ui_channel_info_ban_member_error ChannelInfoViewEvent.UnbanMemberError, - -> R.string.stream_ui_channel_info_unban_member_error + -> R.string.stream_ui_channel_info_unban_member_error ChannelInfoViewEvent.RemoveMemberError, - -> R.string.stream_ui_channel_info_remove_member_error + -> R.string.stream_ui_channel_info_remove_member_error } Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() } 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..1a6bc464638 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 @@ -67,12 +67,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 getMemberId ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -381,6 +383,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 getChannelId ()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$NavigateToNewChannel : 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$NavigateToNewChannel; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToNewChannel;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToNewChannel; + 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; 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..23ac2ead412 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,15 +19,20 @@ 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.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.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -40,6 +45,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach @@ -81,6 +87,7 @@ public class ChannelInfoMemberViewController( public val events: SharedFlow = _events.asSharedFlow() private lateinit var member: Member + private var distinctChannelId: String? = null init { @Suppress("OPT_IN_USAGE") @@ -96,21 +103,55 @@ public class ChannelInfoMemberViewController( channel.members .mapNotNull { members -> members.firstOrNull { it.getUserId() == memberId } } .onEach { logger.d { "[onMember] name: ${it.user.name}" } }, + queryDistinctChannel(), ::ChannelInfoMemberData, ) } .distinctUntilChanged() - .onEach { (channelData, member) -> - onChannelInfoData(channelData, member) + .onEach { (channelData, member, distinctChannelId) -> + onChannelInfoData(channelData, member, distinctChannelId) } .launchIn(scope) } + private fun queryDistinctChannel(): Flow = + flow { + val currentUserId = requireNotNull(chatClient.getCurrentUser()?.id) + 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.cid) + } + } + .onErrorSuspend { + logger.e { "[queryDistinctChannel] Error querying distinct channel of member: $memberId" } + emit(null) + } + } + private fun onChannelInfoData( channelData: ChannelData, member: Member, + distinctChannelId: String?, ) { this.member = member + this.distinctChannelId = distinctChannelId _state.update { ChannelInfoMemberViewState.Content( @@ -134,8 +175,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, distinctChannelId)) is ChannelInfoMemberViewAction.BanMemberClick -> _events.tryEmit(ChannelInfoMemberViewEvent.BanMember(member)) @@ -152,11 +192,11 @@ public class ChannelInfoMemberViewController( private data class ChannelInfoMemberData( val channelData: ChannelData, val member: Member, + val distinctChannelId: String?, ) 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..adf2a5fe940 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 channelId The ID of the channel to navigate to. Null if there is no distinct channel. */ - public data class MessageMember(val channelId: String) : ChannelInfoMemberViewEvent + public data class MessageMember(val memberId: String, val channelId: 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..d5631c2eb28 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 @@ -194,8 +194,13 @@ 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 -> { + event.channelId?.let { channelId -> + _events.tryEmit(ChannelInfoViewEvent.NavigateToChannel(channelId)) + } ?: { + _events.tryEmit(ChannelInfoViewEvent.NavigateToNewChannel(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..b7925fad74f 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 [channelId]. + * + * @param channelId The ID of the channel to navigate to. + */ + public data class NavigateToChannel(val channelId: String) : Navigation(reason = null) + + /** + * Indicates an event to navigate to a new channel creation. + * + * @param memberId The ID of the member to be added to the new channel. + */ + public data class NavigateToNewChannel(val memberId: String) : Navigation(reason = null) + /** * Represents error events occurred while performing an action. */ 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..918258e80c5 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 @@ -31,14 +31,18 @@ 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 +60,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 +69,9 @@ internal class ChannelInfoMemberViewControllerTest { assertEquals( ChannelInfoMemberViewState.Content( member = member, - options = emptyList(), + options = listOf( + ChannelInfoMemberViewState.Content.Option.MessageMember(member), + ), ), awaitItem(), ) @@ -82,6 +88,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 +104,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 +115,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 +126,42 @@ 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(), + channelId = null, + ), + awaitItem(), + ) + } + } + } + + @Test + fun `message member click with distinct channel`() = runTest { + val member = randomMember() + val distinctChannel = randomChannel() + val fixture = Fixture() + .given( + channel = randomChannel(members = listOf(member)), + memberId = member.getUserId(), + distinctChannel = distinctChannel, + ) + 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(), + channelId = distinctChannel.cid, + ), + awaitItem(), + ) } } } @@ -184,15 +227,27 @@ 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, + ) = 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(any())) doReturn listOf(distinctChannel).asCall() + } } fun get(scope: CoroutineScope) = ChannelInfoMemberViewController( From 13c1101cf2a6fcdec3556fe0231f4a657b2e7891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 27 May 2025 15:18:35 +0100 Subject: [PATCH 04/12] Navigate to new channel --- .../ui/channel/DirectChannelInfoActivity.kt | 35 +++++++---- .../ui/channel/GroupChannelInfoActivity.kt | 30 +++------ .../compose/sample/ui/chats/ChatsActivity.kt | 62 +++++++++++-------- .../channel/info/ChannelInfoViewController.kt | 41 ++++++++++-- .../channel/info/ChannelInfoViewEvent.kt | 12 ++-- .../src/main/res/values/strings.xml | 1 + 6 files changed, 107 insertions(+), 74 deletions(-) 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..dc68738e43a 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,32 @@ 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() + + is ChannelInfoViewEvent.NavigateToChannel -> + // No need to handle this in DirectChannelInfoActivity, + // as it is only applicable for group channels. + Unit + } + } + private fun openPinnedMessages() { val intent = PinnedMessagesActivity.createIntent( context = this, @@ -129,6 +137,9 @@ class DirectChannelInfoActivity : BaseConnectedActivity() { ChannelInfoViewEvent.UnbanMemberError, -> R.string.stream_ui_channel_info_unban_member_error + + ChannelInfoViewEvent.NewDirectChannelError, + -> R.string.stream_ui_channel_info_new_direct_channel_error } Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() } 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 1e7287d5796..a5d5b8e99bc 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,6 @@ 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.add.AddChannelActivity 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 @@ -77,20 +76,16 @@ class GroupChannelInfoActivity : BaseConnectedActivity() { LaunchedEffect(viewModel) { viewModel.events.collectLatest { event -> when (event) { - is ChannelInfoViewEvent.Error -> - showError(event) - - is ChannelInfoViewEvent.Navigation -> - handleNavigationEvent(event) - - else -> Unit + is ChannelInfoViewEvent.Error -> showError(event) + is ChannelInfoViewEvent.Navigation -> onNavigationEvent(event) + is ChannelInfoViewEvent.Modal -> Unit } } } } } - private fun handleNavigationEvent(event: ChannelInfoViewEvent.Navigation) { + private fun onNavigationEvent(event: ChannelInfoViewEvent.Navigation) { when (event) { is ChannelInfoViewEvent.NavigateUp -> { setResult(RESULT_OK) @@ -101,21 +96,9 @@ class GroupChannelInfoActivity : BaseConnectedActivity() { openPinnedMessages() is ChannelInfoViewEvent.NavigateToChannel -> { - val intent = MessagesActivity.createIntent( - context = this, - channelId = event.channelId, - ) + val intent = MessagesActivity.createIntent(context = this, channelId = event.channelId) startActivity(intent) } - - is ChannelInfoViewEvent.NavigateToNewChannel -> { - // TODO NavigateToNewChannel - // val intent = AddChannelActivity.createIntent( - // context = this, - // memberId = event.memberId, - // ) - startActivity(Intent(this, AddChannelActivity::class.java)) - } } } @@ -154,6 +137,9 @@ class GroupChannelInfoActivity : BaseConnectedActivity() { ChannelInfoViewEvent.RemoveMemberError, -> R.string.stream_ui_channel_info_remove_member_error + + ChannelInfoViewEvent.NewDirectChannelError, + -> R.string.stream_ui_channel_info_new_direct_channel_error } Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() } 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 a6432618565..cc1bfaec119 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 @@ -262,8 +265,7 @@ class ChatsActivity : BaseConnectedActivity() { onNavigateToChannel = { channelId -> navigator.navigateToChannel( channelId = channelId, - replace = !singlePane, - popUp = !singlePane, + singlePane = singlePane, ) }, ) @@ -275,8 +277,7 @@ class ChatsActivity : BaseConnectedActivity() { navigator.navigateToMessage( channelId = message.cid, messageId = message.id, - replace = !singlePane, - popUp = singlePane, + singlePane = singlePane, ) }, ) @@ -331,7 +332,11 @@ class ChatsActivity : BaseConnectedActivity() { val viewModelFactory = ChannelInfoViewModelFactory(context = applicationContext, cid = channelId) val viewModel = viewModel(key = channelId, factory = viewModelFactory) - viewModel.handleChannelInfoEvents(onNavigateUp, onNavigateToPinnedMessages, onNavigateToChannel) + viewModel.handleChannelInfoEvents( + onNavigateUp = onNavigateUp, + onNavigateToPinnedMessages = onNavigateToPinnedMessages, + onNavigateToChannel = onNavigateToChannel, + ) if (AdaptiveLayoutInfo.singlePaneWindow()) { GroupChannelInfoScreen( @@ -363,11 +368,13 @@ class ChatsActivity : BaseConnectedActivity() { LaunchedEffect(this) { events.collectLatest { event -> when (event) { - is ChannelInfoViewEvent.NavigateUp -> onNavigateUp() - is ChannelInfoViewEvent.NavigateToPinnedMessages -> onNavigateToPinnedMessages() - is ChannelInfoViewEvent.NavigateToChannel -> onNavigateToChannel(event.channelId) + is ChannelInfoViewEvent.Navigation -> when (event) { + is ChannelInfoViewEvent.NavigateUp -> onNavigateUp() + is ChannelInfoViewEvent.NavigateToPinnedMessages -> onNavigateToPinnedMessages() + is ChannelInfoViewEvent.NavigateToChannel -> onNavigateToChannel(event.channelId) + } is ChannelInfoViewEvent.Error -> showError(event) - else -> Unit + is ChannelInfoViewEvent.Modal -> Unit } } } @@ -472,16 +479,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 @@ -491,19 +497,18 @@ private fun ThreePaneNavigator.navigateToMessage( private fun ThreePaneNavigator.navigateToChannel( channelId: String, - replace: Boolean, - popUp: Boolean, + singlePane: Boolean, ) { navigateTo( destination = ThreePaneDestination( pane = ThreePaneRole.Detail, arguments = ChatMessageSelection(channelId), ), - replace = replace, - popUpTo = if (popUp) { - ThreePaneRole.Detail - } else { + replace = !singlePane, + popUpTo = if (singlePane) { null + } else { + ThreePaneRole.Detail }, ) } @@ -511,30 +516,33 @@ private fun ThreePaneNavigator.navigateToChannel( private fun Context.showError(error: ChannelInfoViewEvent.Error) { val message = when (error) { ChannelInfoViewEvent.RenameChannelError, - -> R.string.stream_ui_channel_info_rename_group_error + -> R.string.stream_ui_channel_info_rename_group_error ChannelInfoViewEvent.MuteChannelError, ChannelInfoViewEvent.UnmuteChannelError, - -> R.string.stream_ui_channel_info_mute_conversation_error + -> R.string.stream_ui_channel_info_mute_conversation_error ChannelInfoViewEvent.HideChannelError, ChannelInfoViewEvent.UnhideChannelError, - -> R.string.stream_ui_channel_info_hide_conversation_error + -> R.string.stream_ui_channel_info_hide_conversation_error ChannelInfoViewEvent.LeaveChannelError, - -> R.string.stream_ui_channel_info_leave_conversation_error + -> R.string.stream_ui_channel_info_leave_conversation_error ChannelInfoViewEvent.DeleteChannelError, - -> R.string.stream_ui_channel_info_delete_conversation_error + -> R.string.stream_ui_channel_info_delete_conversation_error ChannelInfoViewEvent.BanMemberError, - -> R.string.stream_ui_channel_info_ban_member_error + -> R.string.stream_ui_channel_info_ban_member_error ChannelInfoViewEvent.UnbanMemberError, - -> R.string.stream_ui_channel_info_unban_member_error + -> R.string.stream_ui_channel_info_unban_member_error ChannelInfoViewEvent.RemoveMemberError, - -> R.string.stream_ui_channel_info_remove_member_error + -> R.string.stream_ui_channel_info_remove_member_error + + ChannelInfoViewEvent.NewDirectChannelError, + -> R.string.stream_ui_channel_info_new_direct_channel_error } Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() } 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 d5631c2eb28..2b8f2b06c20 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 @@ -21,12 +21,14 @@ package io.getstream.chat.android.ui.common.feature.channel.info import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.client.query.CreateChannelParams 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.Member +import io.getstream.chat.android.models.MemberData import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.state.extensions.watchChannelAsState @@ -194,12 +196,10 @@ public class ChannelInfoViewController( public fun onMemberViewEvent(event: ChannelInfoMemberViewEvent) { logger.d { "[onMemberViewEvent] event: $event" } when (event) { - is ChannelInfoMemberViewEvent.MessageMember -> { - event.channelId?.let { channelId -> - _events.tryEmit(ChannelInfoViewEvent.NavigateToChannel(channelId)) - } ?: { - _events.tryEmit(ChannelInfoViewEvent.NavigateToNewChannel(event.memberId)) - } + is ChannelInfoMemberViewEvent.MessageMember -> if (event.channelId != null) { + _events.tryEmit(ChannelInfoViewEvent.NavigateToChannel(event.channelId)) + } else { + createDirectChannel(event.memberId) } is ChannelInfoMemberViewEvent.BanMember -> @@ -412,6 +412,35 @@ public class ChannelInfoViewController( private fun List.filterNotCurrentUser() = filter { member -> member.user.id != chatClient.getCurrentUser()?.id } + + private fun createDirectChannel(memberId: String) { + logger.d { "[createDirectChannel] memberId: $memberId" } + + val onError: (Error) -> Unit = { error -> + logger.e { "[createDirectChannel] error: ${error.message}" } + _events.tryEmit(ChannelInfoViewEvent.NewDirectChannelError) + } + + runCatching { + requireNotNull(chatClient.getCurrentUser()?.id) { "User not connected" } + }.onSuccess { currentUserId -> + scope.launch { + val params = CreateChannelParams( + members = listOf(memberId, currentUserId).map(::MemberData), + extraData = emptyMap(), + ) + chatClient + .createChannel(channelType = "messaging", channelId = "", params = params) + .await() + .onSuccess { channel -> + _events.tryEmit(ChannelInfoViewEvent.NavigateToChannel(channel.cid)) + } + .onError(onError) + } + }.onFailure { cause -> + onError(Error.ThrowableError(message = cause.message.orEmpty(), cause = cause)) + } + } } private const val MINIMUM_VISIBLE_MEMBERS = 5 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 b7925fad74f..1432a955666 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 @@ -150,13 +150,6 @@ public sealed interface ChannelInfoViewEvent { */ public data class NavigateToChannel(val channelId: String) : Navigation(reason = null) - /** - * Indicates an event to navigate to a new channel creation. - * - * @param memberId The ID of the member to be added to the new channel. - */ - public data class NavigateToNewChannel(val memberId: String) : Navigation(reason = null) - /** * Represents error events occurred while performing an action. */ @@ -211,4 +204,9 @@ public sealed interface ChannelInfoViewEvent { * Indicates an error occurred while removing a member. */ public data object RemoveMemberError : Error + + /** + * Indicates an error occurred while creating a new direct channel. + */ + public data object NewDirectChannelError : Error } diff --git a/stream-chat-android-ui-common/src/main/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml index 7ceca2f6a4d..fbfd8cfc505 100644 --- a/stream-chat-android-ui-common/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-common/src/main/res/values/strings.xml @@ -89,6 +89,7 @@ Failed to ban member Failed to unban member Failed to remove member + Failed to create a new direct channel Mute Conversation Mute Group From 01287024d6603b4e2de9bca2d3c46f0f87d37475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 28 May 2025 09:28:16 +0100 Subject: [PATCH 05/12] Refactor: Convert `ChannelInfoMemberViewController.state` to use `stateIn` This commit refactors the `state` property in `ChannelInfoMemberViewController` to utilize the `stateIn` operator. This simplifies the state management by directly transforming the upstream flow into a `StateFlow`. The `onChannelInfoData` function is removed as its logic is now incorporated into the `map` operation within the `stateIn` flow. --- .../info/ChannelInfoMemberViewController.kt | 85 ++++++++----------- 1 file changed, 34 insertions(+), 51 deletions(-) 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 23ac2ead412..da8e27d2842 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 @@ -34,22 +34,21 @@ 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.flow -import kotlinx.coroutines.flow.launchIn +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. @@ -72,34 +71,15 @@ 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() - - private val _events = MutableSharedFlow(extraBufferCapacity = 1) - - /** - * A [SharedFlow] that emits one-time events related to channel info, such as errors or success events. - */ - public val events: SharedFlow = _events.asSharedFlow() - - private lateinit var member: Member - private var distinctChannelId: String? = null - - init { - @Suppress("OPT_IN_USAGE") + @OptIn(ExperimentalCoroutinesApi::class) + public val state: StateFlow = channelState .flatMapLatest { channel -> - logger.d { "[onChannelState]" } combine( - channel.channelData.onEach { - logger.d { - "[onChannelData] cid: ${it.cid}, name: ${it.name}, capabilities: ${it.ownCapabilities}" - } - }, + channel.channelData, channel.members .mapNotNull { members -> members.firstOrNull { it.getUserId() == memberId } } .onEach { logger.d { "[onMember] name: ${it.user.name}" } }, @@ -107,12 +87,32 @@ public class ChannelInfoMemberViewController( ::ChannelInfoMemberData, ) } - .distinctUntilChanged() - .onEach { (channelData, member, distinctChannelId) -> - onChannelInfoData(channelData, member, distinctChannelId) + .map { (channelData, member, distinctChannelId) -> + this.member = member + this.distinctChannelId = distinctChannelId + ChannelInfoMemberViewState.Content( + member = member, + options = buildOptionList( + member = member, + capabilities = channelData.ownCapabilities, + ), + ) } - .launchIn(scope) - } + .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. + */ + public val events: SharedFlow = _events.asSharedFlow() + + private lateinit var member: Member + private var distinctChannelId: String? = null private fun queryDistinctChannel(): Flow = flow { @@ -145,25 +145,6 @@ public class ChannelInfoMemberViewController( } } - private fun onChannelInfoData( - channelData: ChannelData, - member: Member, - distinctChannelId: String?, - ) { - this.member = member - this.distinctChannelId = distinctChannelId - - _state.update { - ChannelInfoMemberViewState.Content( - member = member, - options = buildOptionList( - member = member, - capabilities = channelData.ownCapabilities, - ), - ) - } - } - /** * Handles actions related to channel member information view. * @@ -189,6 +170,8 @@ public class ChannelInfoMemberViewController( } } +private const val STOP_TIMEOUT_IN_MILLIS = 5_000L + private data class ChannelInfoMemberData( val channelData: ChannelData, val member: Member, From 667a70b8ba7b7b734c5d1e82f104ed04c179359c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 28 May 2025 10:11:23 +0100 Subject: [PATCH 06/12] Refactor: Use full `cid` instead of `channelId` for channel navigation The `channelId` parameter, representing only the ID part of a channel identifier, has been replaced with `cid` or `distinctCid`, which represent the full channel identifier (e.g., `messaging:channelId`). This change affects: - `ChannelInfoViewEvent.NavigateToChannel` - `ChannelInfoMemberViewEvent.MessageMember` - Related event handling in `ChannelInfoViewController` and `ChannelInfoMemberViewController` - Sample app usage in `ChatsActivity` and `GroupChannelInfoActivity` This ensures clarity and consistency when referring to channel identifiers. --- .../sample/ui/channel/GroupChannelInfoActivity.kt | 2 +- .../compose/sample/ui/chats/ChatsActivity.kt | 6 +++--- .../info/ChannelInfoMemberViewController.kt | 15 ++++++++------- .../channel/info/ChannelInfoMemberViewEvent.kt | 4 ++-- .../channel/info/ChannelInfoViewController.kt | 4 ++-- .../feature/channel/info/ChannelInfoViewEvent.kt | 6 +++--- .../info/ChannelInfoMemberViewControllerTest.kt | 4 ++-- 7 files changed, 21 insertions(+), 20 deletions(-) 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 a5d5b8e99bc..0a90a1527cb 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 @@ -96,7 +96,7 @@ class GroupChannelInfoActivity : BaseConnectedActivity() { openPinnedMessages() is ChannelInfoViewEvent.NavigateToChannel -> { - val intent = MessagesActivity.createIntent(context = this, channelId = event.channelId) + val intent = MessagesActivity.createIntent(context = this, channelId = event.cid) startActivity(intent) } } 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 cc1bfaec119..e74a3df9e61 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 @@ -327,7 +327,7 @@ class ChatsActivity : BaseConnectedActivity() { onNavigationIconClick: () -> Unit, onNavigateUp: () -> Unit, onNavigateToPinnedMessages: () -> Unit, - onNavigateToChannel: (channelId: String) -> Unit, + onNavigateToChannel: (cid: String) -> Unit, ) { val viewModelFactory = ChannelInfoViewModelFactory(context = applicationContext, cid = channelId) val viewModel = viewModel(key = channelId, factory = viewModelFactory) @@ -363,7 +363,7 @@ class ChatsActivity : BaseConnectedActivity() { private fun ChannelInfoViewModel.handleChannelInfoEvents( onNavigateUp: () -> Unit, onNavigateToPinnedMessages: () -> Unit, - onNavigateToChannel: (channelId: String) -> Unit = {}, + onNavigateToChannel: (cid: String) -> Unit = {}, ) { LaunchedEffect(this) { events.collectLatest { event -> @@ -371,7 +371,7 @@ class ChatsActivity : BaseConnectedActivity() { is ChannelInfoViewEvent.Navigation -> when (event) { is ChannelInfoViewEvent.NavigateUp -> onNavigateUp() is ChannelInfoViewEvent.NavigateToPinnedMessages -> onNavigateToPinnedMessages() - is ChannelInfoViewEvent.NavigateToChannel -> onNavigateToChannel(event.channelId) + is ChannelInfoViewEvent.NavigateToChannel -> onNavigateToChannel(event.cid) } is ChannelInfoViewEvent.Error -> showError(event) is ChannelInfoViewEvent.Modal -> Unit 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 da8e27d2842..ebe19f4580d 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 @@ -23,6 +23,7 @@ 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 @@ -87,9 +88,9 @@ public class ChannelInfoMemberViewController( ::ChannelInfoMemberData, ) } - .map { (channelData, member, distinctChannelId) -> + .map { (channelData, member, distinctChannel) -> this.member = member - this.distinctChannelId = distinctChannelId + this.distinctCid = distinctChannel?.cid ChannelInfoMemberViewState.Content( member = member, options = buildOptionList( @@ -112,9 +113,9 @@ public class ChannelInfoMemberViewController( public val events: SharedFlow = _events.asSharedFlow() private lateinit var member: Member - private var distinctChannelId: String? = null + private var distinctCid: String? = null - private fun queryDistinctChannel(): Flow = + private fun queryDistinctChannel(): Flow = flow { val currentUserId = requireNotNull(chatClient.getCurrentUser()?.id) logger.d { "[queryDistinctChannel] currentUserId: $currentUserId, memberId: $memberId" } @@ -136,7 +137,7 @@ public class ChannelInfoMemberViewController( } else { val channel = channels.first() logger.d { "[queryDistinctChannel] Found distinct channel: ${channel.cid}" } - emit(channel.cid) + emit(channel) } } .onErrorSuspend { @@ -156,7 +157,7 @@ public class ChannelInfoMemberViewController( logger.d { "[onViewAction] action: $action" } when (action) { is ChannelInfoMemberViewAction.MessageMemberClick -> - _events.tryEmit(ChannelInfoMemberViewEvent.MessageMember(memberId, distinctChannelId)) + _events.tryEmit(ChannelInfoMemberViewEvent.MessageMember(memberId, distinctCid)) is ChannelInfoMemberViewAction.BanMemberClick -> _events.tryEmit(ChannelInfoMemberViewEvent.BanMember(member)) @@ -175,7 +176,7 @@ private const val STOP_TIMEOUT_IN_MILLIS = 5_000L private data class ChannelInfoMemberData( val channelData: ChannelData, val member: Member, - val distinctChannelId: String?, + val distinctChannel: Channel?, ) private fun buildOptionList(member: Member, capabilities: Set) = buildList { 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 adf2a5fe940..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 @@ -29,9 +29,9 @@ public sealed interface ChannelInfoMemberViewEvent { * Indicates an event to proceed with messaging a member. * * @param memberId The ID of the member to message. - * @param channelId The ID of the channel to navigate to. Null if there is no distinct channel. + * @param distinctCid The full distinct channel ID, if any. */ - public data class MessageMember(val memberId: String, 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 2b8f2b06c20..0488de42c29 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 @@ -196,8 +196,8 @@ public class ChannelInfoViewController( public fun onMemberViewEvent(event: ChannelInfoMemberViewEvent) { logger.d { "[onMemberViewEvent] event: $event" } when (event) { - is ChannelInfoMemberViewEvent.MessageMember -> if (event.channelId != null) { - _events.tryEmit(ChannelInfoViewEvent.NavigateToChannel(event.channelId)) + is ChannelInfoMemberViewEvent.MessageMember -> if (event.distinctCid != null) { + _events.tryEmit(ChannelInfoViewEvent.NavigateToChannel(event.distinctCid)) } else { createDirectChannel(event.memberId) } 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 1432a955666..f583a080c36 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 @@ -144,11 +144,11 @@ public sealed interface ChannelInfoViewEvent { public data object NavigateToPinnedMessages : Navigation(reason = null) /** - * Indicates an event to navigate to the channel with the specified [channelId]. + * Indicates an event to navigate to the channel with the specified [cid]. * - * @param channelId The ID of the channel to navigate to. + * @param cid The full channel ID of the channel to navigate to. */ - public data class NavigateToChannel(val channelId: String) : Navigation(reason = null) + public data class NavigateToChannel(val cid: String) : Navigation(reason = null) /** * Represents error events occurred while performing an action. 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 918258e80c5..9165e523e60 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 @@ -129,7 +129,7 @@ internal class ChannelInfoMemberViewControllerTest { assertEquals( ChannelInfoMemberViewEvent.MessageMember( memberId = member.getUserId(), - channelId = null, + distinctCid = null, ), awaitItem(), ) @@ -158,7 +158,7 @@ internal class ChannelInfoMemberViewControllerTest { assertEquals( ChannelInfoMemberViewEvent.MessageMember( memberId = member.getUserId(), - channelId = distinctChannel.cid, + distinctCid = distinctChannel.cid, ), awaitItem(), ) From ac805ef62b3e28e8d24e7f818043a929a1271f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 28 May 2025 10:12:22 +0100 Subject: [PATCH 07/12] Test MessageMember event --- .../info/ChannelInfoViewControllerTest.kt | 142 +++++++++++++++++- 1 file changed, 136 insertions(+), 6 deletions(-) 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..b39a2c02c5e 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 @@ -22,11 +22,13 @@ 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.channel.state.ChannelState +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.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.User import io.getstream.chat.android.models.toChannelData @@ -36,6 +38,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 +971,105 @@ 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 and no connected user`() = 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.NewDirectChannelError, + awaitItem(), + ) + } + } + + launch { fixture.verifyNoMoreInteractions() } + } + + @Test + fun `message member with no distinct channel`() = runTest { + val currentUser = randomUser() + val memberId = randomString() + val channel = randomChannel() + val fixture = Fixture() + .given(currentUser) + .givenCreateDirectChannel(memberId, currentUser.id, channel) + 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.NavigateToChannel(channel.cid), + awaitItem(), + ) + } + } + + launch { + fixture.verifyChannelCreated(memberId, currentUser.id) + fixture.verifyNoMoreInteractions() + } + } + + @Test + fun `message member with no distinct channel and error`() = runTest { + val memberId = randomString() + val currentUser = randomUser() + val fixture = Fixture() + .given(currentUser) + .givenCreateDirectChannel(memberId, currentUser.id, error = randomGenericError()) + 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.NewDirectChannelError, + awaitItem(), + ) + } + } + + launch { fixture.verifyNoMoreInteractions() } + } + @Test fun `ban member modal`() = runTest { val member = randomMember() @@ -1242,15 +1344,33 @@ 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() + } + } + + fun givenCreateDirectChannel( + memberId: String, + currentUserId: String? = null, + channel: Channel? = null, + error: Error? = null, + ) = apply { + whenever( + chatClient.createChannel( + channelType = "messaging", + channelId = "", + params = CreateChannelParams( + members = listOfNotNull(memberId, currentUserId).map(::MemberData), + extraData = emptyMap(), + ), + ), + ) doAnswer { + error?.asCall() ?: channel?.asCall() } } @@ -1262,8 +1382,7 @@ private class Fixture { timeout = timeout, ), ) doAnswer { - error?.asCall() - ?: Unit.asCall() + error?.asCall() ?: Unit.asCall() } } @@ -1282,6 +1401,17 @@ private class Fixture { verify(copyToClipboardHandler).copy(text = text) } + fun verifyChannelCreated(memberId: String, currentUserId: String) = apply { + verify(chatClient).createChannel( + channelType = "messaging", + channelId = "", + params = CreateChannelParams( + members = listOf(memberId, currentUserId).map(::MemberData), + extraData = emptyMap(), + ), + ) + } + fun verifyMemberBanned(member: Member, timeout: Int?) = apply { verify(channelClient).banUser( targetId = member.getUserId(), From 38e53c20923ebe808f533cd789a5b5804290d1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 28 May 2025 10:33:43 +0100 Subject: [PATCH 08/12] Update queryChannels call in `ChannelInfoMemberViewControllerTest` The `queryChannels` call in the `ChannelInfoMemberViewControllerTest` has been updated to use a `QueryChannelsRequest` object instead of an `any()` matcher. This ensures that the test accurately reflects the actual call being made by the `ChannelInfoMemberViewController`. --- .../info/ChannelInfoMemberViewController.kt | 49 ++++++++++--------- .../ChannelInfoMemberViewControllerTest.kt | 24 ++++++++- 2 files changed, 48 insertions(+), 25 deletions(-) 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 ebe19f4580d..b130d4d5a36 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 @@ -117,33 +117,34 @@ public class ChannelInfoMemberViewController( private fun queryDistinctChannel(): Flow = flow { - val currentUserId = requireNotNull(chatClient.getCurrentUser()?.id) - logger.d { "[queryDistinctChannel] currentUserId: $currentUserId, memberId: $memberId" } - chatClient.queryChannels( - request = QueryChannelsRequest( - filter = Filters.and( - Filters.eq("type", "messaging"), - Filters.distinct(listOf(memberId, currentUserId)), + 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, ), - 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" } + ).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) + } + } + .onErrorSuspend { + logger.e { "[queryDistinctChannel] Error querying distinct channel of member: $memberId" } emit(null) - } else { - val channel = channels.first() - logger.d { "[queryDistinctChannel] Found distinct channel: ${channel.cid}" } - emit(channel) } - } - .onErrorSuspend { - logger.e { "[queryDistinctChannel] Error querying distinct channel of member: $memberId" } - emit(null) - } + } } /** 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 9165e523e60..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,12 +20,16 @@ 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 @@ -141,11 +145,13 @@ internal class ChannelInfoMemberViewControllerTest { 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) @@ -237,6 +243,7 @@ internal class ChannelInfoMemberViewControllerTest { channel: Channel? = null, memberId: String? = null, distinctChannel: Channel? = null, + currentUser: User? = null, ) = apply { if (channel != null) { channelData.value = channel.toChannelData() @@ -246,7 +253,22 @@ internal class ChannelInfoMemberViewControllerTest { this.memberId = memberId } if (distinctChannel != null) { - whenever(chatClient.queryChannels(any())) doReturn listOf(distinctChannel).asCall() + 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 } } From fdacd5c2457ce3efb1433e8814c670385fc6988d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 28 May 2025 12:43:34 +0100 Subject: [PATCH 09/12] Refactor: Convert `ChannelInfoMemberViewController.state` to use `stateIn` This commit refactors the `state` property in `ChannelInfoMemberViewController` to utilize the `stateIn` operator. This simplifies the state management by directly transforming the upstream flow into a `StateFlow`. The `onChannelInfoData` function is removed as its logic is now incorporated into the `map` operation within the `stateIn` flow. --- .../info/ChannelInfoMemberViewController.kt | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) 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 b130d4d5a36..b1b91a1e485 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 @@ -77,33 +77,30 @@ public class ChannelInfoMemberViewController( */ @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( + 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, - options = buildOptionList( - member = member, - capabilities = channelData.ownCapabilities, - ), - ) - } - .stateIn( - scope = scope, - started = WhileSubscribed(STOP_TIMEOUT_IN_MILLIS), - initialValue = ChannelInfoMemberViewState.Loading, + capabilities = channelData.ownCapabilities, + ), ) + }.stateIn( + scope = scope, + started = WhileSubscribed(STOP_TIMEOUT_IN_MILLIS), + initialValue = ChannelInfoMemberViewState.Loading, + ) private val _events = MutableSharedFlow(extraBufferCapacity = 1) From ddbde8f5da3302ddfa128be1203bb1f0b41e0f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 28 May 2025 17:04:30 +0100 Subject: [PATCH 10/12] Refactor: Navigate to draft channel instead of creating a new direct channel When messaging a member from the channel info screen, the app will now navigate to a draft channel instead of immediately creating a new direct channel. This allows the user to compose a message before the channel is actually created. This change also introduces the following new components: - `DraftChannelViewController`: Manages the state and events for the draft channel screen. - `DraftChannelViewState`: Represents the state of the draft channel screen. - `DraftChannelViewAction`: Represents user actions on the draft channel screen. - `DraftChannelViewEvent`: Represents side effects from the draft channel screen. - `DraftChannelScreen`: The composable UI for the draft channel. - `DraftChannelActivity`: The activity that hosts the draft channel screen. --- .../src/main/AndroidManifest.xml | 8 +- .../channel/draft/DraftChannelActivity.kt | 92 +++++++ .../channel/draft/DraftChannelScreen.kt | 130 ++++++++++ .../channel/draft/DraftChannelViewModel.kt | 59 +++++ .../draft/DraftChannelViewModelFactory.kt | 32 +++ .../ui/channel/DirectChannelInfoActivity.kt | 12 +- .../ui/channel/GroupChannelInfoActivity.kt | 13 +- .../compose/sample/ui/chats/ChatsActivity.kt | 5 +- .../src/main/res/values/strings.xml | 5 +- .../channel/ChannelHeaderViewModelFactory.kt | 40 ++++ .../channel/draft/DraftChannelViewAction.kt | 31 +++ .../draft/DraftChannelViewController.kt | 128 ++++++++++ .../channel/draft/DraftChannelViewEvent.kt | 38 +++ .../info/ChannelInfoMemberViewController.kt | 4 +- .../channel/info/ChannelInfoViewController.kt | 37 +-- .../channel/info/ChannelInfoViewEvent.kt | 12 +- .../channel/draft/DraftChannelViewState.kt | 42 ++++ .../src/main/res/values/strings.xml | 1 - .../draft/DraftChannelViewControllerTest.kt | 224 ++++++++++++++++++ .../info/ChannelInfoViewControllerTest.kt | 89 +------ 20 files changed, 852 insertions(+), 150 deletions(-) create mode 100644 stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelActivity.kt create mode 100644 stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelScreen.kt create mode 100644 stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelViewModel.kt create mode 100644 stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelViewModelFactory.kt create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelHeaderViewModelFactory.kt create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewAction.kt create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewController.kt create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent.kt create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState.kt create mode 100644 stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewControllerTest.kt 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 dc68738e43a..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 @@ -95,10 +95,11 @@ class DirectChannelInfoActivity : BaseConnectedActivity() { is ChannelInfoViewEvent.NavigateToPinnedMessages -> openPinnedMessages() - is ChannelInfoViewEvent.NavigateToChannel -> - // No need to handle this in DirectChannelInfoActivity, - // as it is only applicable for group channels. - Unit + // No need to handle these in DirectChannelInfoActivity, + // as it is only applicable for group channels. + is ChannelInfoViewEvent.NavigateToChannel, + is ChannelInfoViewEvent.NavigateToDraftChannel, + -> Unit } } @@ -137,9 +138,6 @@ class DirectChannelInfoActivity : BaseConnectedActivity() { ChannelInfoViewEvent.UnbanMemberError, -> R.string.stream_ui_channel_info_unban_member_error - - ChannelInfoViewEvent.NewDirectChannelError, - -> R.string.stream_ui_channel_info_new_direct_channel_error } Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() } 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 0a90a1527cb..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,6 +26,7 @@ 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 @@ -95,10 +96,11 @@ class GroupChannelInfoActivity : BaseConnectedActivity() { is ChannelInfoViewEvent.NavigateToPinnedMessages -> openPinnedMessages() - is ChannelInfoViewEvent.NavigateToChannel -> { - val intent = MessagesActivity.createIntent(context = this, channelId = event.cid) - startActivity(intent) - } + is ChannelInfoViewEvent.NavigateToChannel -> + startActivity(MessagesActivity.createIntent(context = this, channelId = event.cid)) + + is ChannelInfoViewEvent.NavigateToDraftChannel -> + startActivity(DraftChannelActivity.createIntent(context = this, memberIds = listOf(event.memberId))) } } @@ -137,9 +139,6 @@ class GroupChannelInfoActivity : BaseConnectedActivity() { ChannelInfoViewEvent.RemoveMemberError, -> R.string.stream_ui_channel_info_remove_member_error - - ChannelInfoViewEvent.NewDirectChannelError, - -> R.string.stream_ui_channel_info_new_direct_channel_error } Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() } 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 e74a3df9e61..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 @@ -372,6 +372,8 @@ class ChatsActivity : BaseConnectedActivity() { 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) is ChannelInfoViewEvent.Modal -> Unit @@ -540,9 +542,6 @@ private fun Context.showError(error: ChannelInfoViewEvent.Error) { ChannelInfoViewEvent.RemoveMemberError, -> R.string.stream_ui_channel_info_remove_member_error - - ChannelInfoViewEvent.NewDirectChannelError, - -> R.string.stream_ui_channel_info_new_direct_channel_error } Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() } 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 c3dc45472dd..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 @@ -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/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/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 b1b91a1e485..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 @@ -105,7 +105,7 @@ public class ChannelInfoMemberViewController( 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() @@ -147,7 +147,7 @@ public class ChannelInfoMemberViewController( /** * 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, 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 0488de42c29..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 @@ -21,14 +21,12 @@ package io.getstream.chat.android.ui.common.feature.channel.info import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.channel.state.ChannelState -import io.getstream.chat.android.client.query.CreateChannelParams 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.Member -import io.getstream.chat.android.models.MemberData import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.state.extensions.watchChannelAsState @@ -92,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() @@ -161,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, @@ -199,7 +197,7 @@ public class ChannelInfoViewController( is ChannelInfoMemberViewEvent.MessageMember -> if (event.distinctCid != null) { _events.tryEmit(ChannelInfoViewEvent.NavigateToChannel(event.distinctCid)) } else { - createDirectChannel(event.memberId) + _events.tryEmit(ChannelInfoViewEvent.NavigateToDraftChannel(event.memberId)) } is ChannelInfoMemberViewEvent.BanMember -> @@ -412,35 +410,6 @@ public class ChannelInfoViewController( private fun List.filterNotCurrentUser() = filter { member -> member.user.id != chatClient.getCurrentUser()?.id } - - private fun createDirectChannel(memberId: String) { - logger.d { "[createDirectChannel] memberId: $memberId" } - - val onError: (Error) -> Unit = { error -> - logger.e { "[createDirectChannel] error: ${error.message}" } - _events.tryEmit(ChannelInfoViewEvent.NewDirectChannelError) - } - - runCatching { - requireNotNull(chatClient.getCurrentUser()?.id) { "User not connected" } - }.onSuccess { currentUserId -> - scope.launch { - val params = CreateChannelParams( - members = listOf(memberId, currentUserId).map(::MemberData), - extraData = emptyMap(), - ) - chatClient - .createChannel(channelType = "messaging", channelId = "", params = params) - .await() - .onSuccess { channel -> - _events.tryEmit(ChannelInfoViewEvent.NavigateToChannel(channel.cid)) - } - .onError(onError) - } - }.onFailure { cause -> - onError(Error.ThrowableError(message = cause.message.orEmpty(), cause = cause)) - } - } } private const val MINIMUM_VISIBLE_MEMBERS = 5 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 f583a080c36..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 @@ -150,6 +150,13 @@ public sealed interface ChannelInfoViewEvent { */ 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. */ @@ -204,9 +211,4 @@ public sealed interface ChannelInfoViewEvent { * Indicates an error occurred while removing a member. */ public data object RemoveMemberError : Error - - /** - * Indicates an error occurred while creating a new direct channel. - */ - public data object NewDirectChannelError : Error } 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/main/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml index fbfd8cfc505..7ceca2f6a4d 100644 --- a/stream-chat-android-ui-common/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-common/src/main/res/values/strings.xml @@ -89,7 +89,6 @@ Failed to ban member Failed to unban member Failed to remove member - Failed to create a new direct channel Mute Conversation Mute Group 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/ChannelInfoViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewControllerTest.kt index b39a2c02c5e..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 @@ -22,13 +22,11 @@ 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.channel.state.ChannelState -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.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.User import io.getstream.chat.android.models.toChannelData @@ -994,36 +992,10 @@ internal class ChannelInfoViewControllerTest { launch { fixture.verifyNoMoreInteractions() } } - @Test - fun `message member with no distinct channel and no connected user`() = 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.NewDirectChannelError, - awaitItem(), - ) - } - } - - launch { fixture.verifyNoMoreInteractions() } - } - @Test fun `message member with no distinct channel`() = runTest { - val currentUser = randomUser() val memberId = randomString() - val channel = randomChannel() val fixture = Fixture() - .given(currentUser) - .givenCreateDirectChannel(memberId, currentUser.id, channel) val sut = fixture.get(backgroundScope) sut.state.test { @@ -1033,35 +1005,7 @@ internal class ChannelInfoViewControllerTest { sut.onMemberViewEvent(ChannelInfoMemberViewEvent.MessageMember(memberId, distinctCid = null)) assertEquals( - ChannelInfoViewEvent.NavigateToChannel(channel.cid), - awaitItem(), - ) - } - } - - launch { - fixture.verifyChannelCreated(memberId, currentUser.id) - fixture.verifyNoMoreInteractions() - } - } - - @Test - fun `message member with no distinct channel and error`() = runTest { - val memberId = randomString() - val currentUser = randomUser() - val fixture = Fixture() - .given(currentUser) - .givenCreateDirectChannel(memberId, currentUser.id, error = randomGenericError()) - 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.NewDirectChannelError, + ChannelInfoViewEvent.NavigateToDraftChannel(memberId), awaitItem(), ) } @@ -1354,26 +1298,6 @@ private class Fixture { } } - fun givenCreateDirectChannel( - memberId: String, - currentUserId: String? = null, - channel: Channel? = null, - error: Error? = null, - ) = apply { - whenever( - chatClient.createChannel( - channelType = "messaging", - channelId = "", - params = CreateChannelParams( - members = listOfNotNull(memberId, currentUserId).map(::MemberData), - extraData = emptyMap(), - ), - ), - ) doAnswer { - error?.asCall() ?: channel?.asCall() - } - } - fun givenBanMember(member: Member, timeout: Int? = null, error: Error? = null) = apply { whenever( channelClient.banUser( @@ -1401,17 +1325,6 @@ private class Fixture { verify(copyToClipboardHandler).copy(text = text) } - fun verifyChannelCreated(memberId: String, currentUserId: String) = apply { - verify(chatClient).createChannel( - channelType = "messaging", - channelId = "", - params = CreateChannelParams( - members = listOf(memberId, currentUserId).map(::MemberData), - extraData = emptyMap(), - ), - ) - } - fun verifyMemberBanned(member: Member, timeout: Int?) = apply { verify(channelClient).banUser( targetId = member.getUserId(), From 1024faaac4f07b6faed8de88b0a354aef4f55bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 28 May 2025 10:34:57 +0100 Subject: [PATCH 11/12] apiDump --- .../api/stream-chat-android-compose.api | 6 ++ .../api/stream-chat-android-ui-common.api | 67 +++++++++++++++++-- 2 files changed, 68 insertions(+), 5 deletions(-) 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-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index 1a6bc464638..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 { } @@ -73,7 +107,7 @@ public final class io/getstream/chat/android/ui/common/feature/channel/info/Chan 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; @@ -390,17 +424,17 @@ public final class io/getstream/chat/android/ui/common/feature/channel/info/Chan 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 getChannelId ()Ljava/lang/String; + 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$NavigateToNewChannel : io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$Navigation { +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$NavigateToNewChannel; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToNewChannel;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToNewChannel; + 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 @@ -1172,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 { } From 01837224f9135cb9ae68d1cac976608cc15190b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 29 May 2025 09:22:40 +0100 Subject: [PATCH 12/12] CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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