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