Repository: GetStream/stream-chat-android
Pull request #5684: [AND-388] Render pinned messages in the info pane of ChatsScreen
Author: login: andremion
Labels: name: compose
description: Jetpack Compose
Created: 2025-03-18T15:00:48Z | Merged: 2025-03-21T12:50:54Z

### 🎯 Goal

Render pinned messages in the info pane of `ChatsScreen`

### 🛠 Implementation details

- Navigate to `PinnedMessagesScreen`
- Reuse `PinnedMessagesScreen` in `PinnedMessagesActivity`
- Add ripple effect to the `ChannelInfoOptionItem`
- Rename `ListContentMode` to `ChatListContentMode`

### 🎨 UI Changes

<table>
<thead>
<tr>
<th>Phone</th>
<th>Tablet</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<video src="https://github.com/user-attachments/assets/a49a5e02-313f-4819-a479-286cf4c5c887" controls="controls" muted="muted" />
</td>
<td>
<video src="https://github.com/user-attachments/assets/7df5937f-f295-41b9-b0f6-36b441e8447d" controls="controls" muted="muted" />
</td>
</tr>
</tbody>
</table>

### 🧪 Testing

- Enable Adaptive layout in the Advanced options
- Follow the video steps on tablet devices

### 🎉 GIF

![gif](https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExeHlkemxueWo3NDNmZWUwdWVqeDZ6azNrdWpnaDZqN2E2Z3c4YWxwZyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/PhTy277HOzgpeCtdoi/giphy.gif)



Diff:
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/ChannelInfoOptionItem.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/ChannelInfoOptionItem.kt
index c85e9346aee..ecc04f94381 100644
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/ChannelInfoOptionItem.kt
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/ChannelInfoOptionItem.kt
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -71,7 +72,11 @@ fun ChannelInfoOptionItem(
         modifier = Modifier
             .fillMaxWidth()
             .background(ChatTheme.colors.appBackground)
-            .clickable { onClick() }
+            .clickable(
+                interactionSource = null,
+                indication = ripple(),
+                onClick = onClick,
+            )
             .padding(16.dp),
         verticalAlignment = Alignment.CenterVertically,
     ) {
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 4d8a244547f..8ebf33f2758 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
@@ -53,10 +53,11 @@ import io.getstream.chat.android.compose.sample.ui.component.AppBottomBar
 import io.getstream.chat.android.compose.sample.ui.component.AppBottomBarOption
 import io.getstream.chat.android.compose.sample.ui.component.CustomChatComponentFactory
 import io.getstream.chat.android.compose.sample.ui.login.UserLoginActivity
-import io.getstream.chat.android.compose.sample.ui.pinned.PinnedMessagesActivity
+import io.getstream.chat.android.compose.sample.ui.pinned.PinnedMessagesScreen
 import io.getstream.chat.android.compose.ui.channels.SearchMode
+import io.getstream.chat.android.compose.ui.chats.ChatListContentMode
+import io.getstream.chat.android.compose.ui.chats.ChatMessageSelection
 import io.getstream.chat.android.compose.ui.chats.ChatsScreen
-import io.getstream.chat.android.compose.ui.chats.ListContentMode
 import io.getstream.chat.android.compose.ui.components.channels.ChannelOptionItemVisibility
 import io.getstream.chat.android.compose.ui.theme.ChannelOptionsTheme
 import io.getstream.chat.android.compose.ui.theme.ChatTheme
@@ -67,8 +68,11 @@ import io.getstream.chat.android.compose.ui.util.adaptivelayout.ThreePaneRole
 import io.getstream.chat.android.compose.ui.util.adaptivelayout.rememberThreePaneNavigator
 import io.getstream.chat.android.compose.viewmodel.channels.ChannelViewModelFactory
 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.models.Channel
 import io.getstream.chat.android.models.Filters
+import io.getstream.chat.android.models.Message
 import io.getstream.chat.android.models.querysort.QuerySortByField
 import io.getstream.chat.android.state.extensions.globalState
 import io.getstream.chat.android.ui.common.state.messages.list.DeletedMessageVisibility
@@ -148,7 +152,7 @@ class ChatsActivity : BaseConnectedActivity() {
 
     @Composable
     private fun ScreenContent() {
-        var listContentMode by rememberSaveable { mutableStateOf(ListContentMode.Channels) }
+        var listContentMode by rememberSaveable { mutableStateOf(ChatListContentMode.Channels) }
         val navigator = rememberThreePaneNavigator()
         ChatsScreen(
             navigator = navigator,
@@ -182,44 +186,32 @@ class ChatsActivity : BaseConnectedActivity() {
                     listContentMode = listContentMode,
                     onOptionSelected = { option ->
                         listContentMode = when (option) {
-                            AppBottomBarOption.CHATS -> ListContentMode.Channels
-                            AppBottomBarOption.THREADS -> ListContentMode.Threads
+                            AppBottomBarOption.CHATS -> ChatListContentMode.Channels
+                            AppBottomBarOption.THREADS -> ChatListContentMode.Threads
                         }
                     },
                 )
             },
             infoContent = { arguments ->
-
-                BackHandler(enabled = navigator.canNavigateBack()) { navigator.navigateBack() }
-
-                when (arguments) {
-                    is InfoContentMode.SingleChannelInfo -> SingleChannelInfoContent(
-                        channelId = arguments.channelId,
-                        onNavigationIconClick = { navigator.navigateBack() },
-                    )
-
-                    is InfoContentMode.GroupChannelInfo -> GroupChannelInfoContent(
-                        channelId = arguments.channelId,
-                        onNavigationIconClick = { navigator.navigateBack() },
-                    )
-
-                    is InfoContentMode.Hidden -> Unit
-                }
+                InfoContent(
+                    navigator = navigator,
+                    mode = arguments as InfoContentMode,
+                )
             },
         )
     }
 
     @Composable
     private fun ListFooterContent(
-        listContentMode: ListContentMode,
+        listContentMode: ChatListContentMode,
         onOptionSelected: (option: AppBottomBarOption) -> Unit,
     ) {
         val globalState = ChatClient.instance().globalState
         val unreadChannelsCount by globalState.channelUnreadCount.collectAsState()
         val unreadThreadsCount by globalState.unreadThreadsCount.collectAsState()
         val selectedOption = when (listContentMode) {
-            ListContentMode.Channels -> AppBottomBarOption.CHATS
-            ListContentMode.Threads -> AppBottomBarOption.THREADS
+            ChatListContentMode.Channels -> AppBottomBarOption.CHATS
+            ChatListContentMode.Threads -> AppBottomBarOption.THREADS
         }
         AppBottomBar(
             unreadChannelsCount = unreadChannelsCount,
@@ -229,10 +221,49 @@ class ChatsActivity : BaseConnectedActivity() {
         )
     }
 
+    @Composable
+    private fun InfoContent(
+        navigator: ThreePaneNavigator,
+        mode: InfoContentMode,
+    ) {
+        val singlePane = AdaptiveLayoutInfo.singlePaneWindow()
+        BackHandler(enabled = navigator.canNavigateBack()) { navigator.navigateBack() }
+
+        when (mode) {
+            is InfoContentMode.SingleChannelInfo -> SingleChannelInfoContent(
+                channelId = mode.channelId,
+                onNavigationIconClick = { navigator.navigateBack() },
+                onPinnedMessagesClick = { navigator.navigateToPinnedMessages(mode.channelId) },
+            )
+
+            is InfoContentMode.GroupChannelInfo -> GroupChannelInfoContent(
+                channelId = mode.channelId,
+                onNavigationIconClick = { navigator.navigateBack() },
+                onPinnedMessagesClick = { navigator.navigateToPinnedMessages(mode.channelId) },
+            )
+
+            is InfoContentMode.PinnedMessages -> PinnedMessagesContent(
+                channelId = mode.channelId,
+                onNavigationIconClick = { navigator.navigateBack() },
+                onMessageClick = { message ->
+                    navigator.navigateToMessage(
+                        channelId = message.cid,
+                        messageId = message.id,
+                        replace = !singlePane,
+                        popUp = singlePane,
+                    )
+                },
+            )
+
+            is InfoContentMode.Hidden -> Unit
+        }
+    }
+
     @Composable
     private fun SingleChannelInfoContent(
         channelId: String,
         onNavigationIconClick: () -> Unit,
+        onPinnedMessagesClick: () -> Unit,
     ) {
         val viewModel = viewModel(
             ChannelInfoViewModel::class.java,
@@ -240,24 +271,27 @@ class ChatsActivity : BaseConnectedActivity() {
             factory = ChannelInfoViewModelFactory(channelId),
         )
         val state by viewModel.state.collectAsState()
-        ChannelInfoScreen(
-            state = state,
-            onPinnedMessagesClick = { openPinnedMessages(channelId) },
-            onConfirmDelete = viewModel::onDeleteChannel,
-            navigationIcon = {
-                if (AdaptiveLayoutInfo.singlePaneWindow()) {
-                    DefaultChannelInfoNavigationIcon(onClick = onNavigationIconClick)
-                } else {
-                    CloseButton(onClick = onNavigationIconClick)
-                }
-            },
-        )
+        if (state.member != null) {
+            ChannelInfoScreen(
+                state = state,
+                onPinnedMessagesClick = onPinnedMessagesClick,
+                onConfirmDelete = viewModel::onDeleteChannel,
+                navigationIcon = {
+                    if (AdaptiveLayoutInfo.singlePaneWindow()) {
+                        DefaultChannelInfoNavigationIcon(onClick = onNavigationIconClick)
+                    } else {
+                        CloseButton(onClick = onNavigationIconClick)
+                    }
+                },
+            )
+        }
     }
 
     @Composable
     private fun GroupChannelInfoContent(
         channelId: String,
         onNavigationIconClick: () -> Unit,
+        onPinnedMessagesClick: () -> Unit,
     ) {
         val viewModel = viewModel(
             GroupChannelInfoViewModel::class.java,
@@ -268,7 +302,7 @@ class ChatsActivity : BaseConnectedActivity() {
         GroupChannelInfoScreen(
             state = state,
             onNavigationIconClick = onNavigationIconClick,
-            onPinnedMessagesClick = { openPinnedMessages(channelId) },
+            onPinnedMessagesClick = onPinnedMessagesClick,
             navigationIcon = if (AdaptiveLayoutInfo.singlePaneWindow()) {
                 null
             } else {
@@ -277,6 +311,24 @@ class ChatsActivity : BaseConnectedActivity() {
         )
     }
 
+    @Composable
+    private fun PinnedMessagesContent(
+        channelId: String,
+        onNavigationIconClick: () -> Unit,
+        onMessageClick: (message: Message) -> Unit,
+    ) {
+        val viewModel = viewModel(
+            PinnedMessageListViewModel::class.java,
+            key = channelId,
+            factory = PinnedMessageListViewModelFactory(channelId),
+        )
+        PinnedMessagesScreen(
+            viewModel = viewModel,
+            onNavigationIconClick = onNavigationIconClick,
+            onMessageClick = onMessageClick,
+        )
+    }
+
     private fun buildMessagesViewModelFactory(
         channelId: String,
         messageId: String?,
@@ -298,10 +350,6 @@ class ChatsActivity : BaseConnectedActivity() {
     private fun openUserLogin() {
         startActivity(UserLoginActivity.createIntent(applicationContext))
     }
-
-    private fun openPinnedMessages(channelId: String) {
-        startActivity(PinnedMessagesActivity.createIntent(applicationContext, channelId))
-    }
 }
 
 @Composable
@@ -328,5 +376,34 @@ private fun ThreePaneNavigator.navigateToChannelInfo(channel: Channel) {
     )
 }
 
+private fun ThreePaneNavigator.navigateToPinnedMessages(channelId: String) {
+    navigateTo(
+        destination = ThreePaneDestination(
+            pane = ThreePaneRole.Info,
+            arguments = InfoContentMode.PinnedMessages(channelId),
+        ),
+    )
+}
+
+private fun ThreePaneNavigator.navigateToMessage(
+    channelId: String,
+    messageId: String,
+    replace: Boolean,
+    popUp: Boolean,
+) {
+    navigateTo(
+        destination = ThreePaneDestination(
+            pane = ThreePaneRole.Detail,
+            arguments = ChatMessageSelection(channelId, messageId),
+        ),
+        replace = replace,
+        popUpTo = if (popUp) {
+            ThreePaneRole.List
+        } else {
+            null
+        },
+    )
+}
+
 private val Channel.isGroupChannel: Boolean
     get() = memberCount > 2 || !isAnonymousChannel()
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/InfoContentMode.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/InfoContentMode.kt
index 67d7810788b..42a6949e5f8 100644
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/InfoContentMode.kt
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/InfoContentMode.kt
@@ -42,4 +42,9 @@ sealed class InfoContentMode(open val channelId: String) : Serializable {
      * Display the info for a group channel.
      */
     data class GroupChannelInfo(override val channelId: String) : InfoContentMode(channelId)
+
+    /**
+     * Display the pinned messages for a channel.
+     */
+    data class PinnedMessages(override val channelId: String) : InfoContentMode(channelId)
 }
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/pinned/PinnedMessagesActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/pinned/PinnedMessagesActivity.kt
index 68c7d2848e9..46fb816d9ab 100644
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/pinned/PinnedMessagesActivity.kt
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/pinned/PinnedMessagesActivity.kt
@@ -21,21 +21,10 @@ import android.content.Intent
 import android.os.Bundle
 import androidx.activity.compose.setContent
 import androidx.activity.viewModels
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.material3.Scaffold
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import io.getstream.chat.android.compose.sample.R
-import io.getstream.chat.android.compose.sample.data.customSettings
 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.chats.ChatsActivity
-import io.getstream.chat.android.compose.sample.ui.component.AppToolbar
-import io.getstream.chat.android.compose.ui.pinned.PinnedMessageList
 import io.getstream.chat.android.compose.ui.theme.ChatTheme
 import io.getstream.chat.android.compose.viewmodel.pinned.PinnedMessageListViewModel
 import io.getstream.chat.android.compose.viewmodel.pinned.PinnedMessageListViewModelFactory
@@ -66,55 +55,28 @@ class PinnedMessagesActivity : BaseConnectedActivity() {
         )
     }
     private val viewModel by viewModels<PinnedMessageListViewModel> { viewModelFactory }
-    private val settings by lazy { customSettings() }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContent {
             ChatTheme {
-                Scaffold(
+                PinnedMessagesScreen(
                     modifier = Modifier.statusBarsPadding(),
-                    topBar = {
-                        AppToolbar(
-                            title = stringResource(id = R.string.channel_info_option_pinned_messages),
-                            onBack = ::finish,
-                        )
-                    },
-                    content = { padding ->
-                        Box(
-                            modifier = Modifier
-                                .fillMaxSize()
-                                .background(ChatTheme.colors.appBackground)
-                                .padding(padding),
-                        ) {
-                            PinnedMessageList(
-                                modifier = Modifier.fillMaxSize(),
-                                viewModel = viewModel,
-                                onPinnedMessageClick = ::openMessage,
-                            )
-                        }
-                    },
+                    viewModel = viewModel,
+                    onNavigationIconClick = ::finish,
+                    onMessageClick = ::openMessage,
                 )
             }
         }
     }
 
     private fun openMessage(message: Message) {
-        val intent = if (settings.isAdaptiveLayoutEnabled) {
-            ChatsActivity.createIntent(
-                context = applicationContext,
-                channelId = message.cid,
-                messageId = message.id,
-                parentMessageId = message.parentId,
-            )
-        } else {
-            MessagesActivity.createIntent(
-                context = applicationContext,
-                channelId = message.cid,
-                messageId = message.id,
-                parentMessageId = message.parentId,
-            )
-        }
+        val intent = MessagesActivity.createIntent(
+            context = applicationContext,
+            channelId = message.cid,
+            messageId = message.id,
+            parentMessageId = message.parentId,
+        )
         startActivity(intent)
     }
 }
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/pinned/PinnedMessagesScreen.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/pinned/PinnedMessagesScreen.kt
new file mode 100644
index 00000000000..e84b571eb27
--- /dev/null
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/pinned/PinnedMessagesScreen.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.ui.pinned
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import io.getstream.chat.android.compose.sample.R
+import io.getstream.chat.android.compose.sample.ui.component.AppToolbar
+import io.getstream.chat.android.compose.ui.pinned.PinnedMessageList
+import io.getstream.chat.android.compose.viewmodel.pinned.PinnedMessageListViewModel
+import io.getstream.chat.android.models.Message
+
+@Composable
+fun PinnedMessagesScreen(
+    viewModel: PinnedMessageListViewModel,
+    onNavigationIconClick: () -> Unit,
+    onMessageClick: (message: Message) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Scaffold(
+        modifier = modifier,
+        topBar = {
+            AppToolbar(
+                title = stringResource(id = R.string.channel_info_option_pinned_messages),
+                onBack = onNavigationIconClick,
+            )
+        },
+        content = { padding ->
+            PinnedMessageList(
+                modifier = Modifier
+                    .padding(padding)
+                    .fillMaxSize(),
+                viewModel = viewModel,
+                onPinnedMessageClick = onMessageClick,
+            )
+        },
+    )
+}
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 ef755e444e7..dbc6f7eecd9 100644
--- a/stream-chat-android-compose/api/stream-chat-android-compose.api
+++ b/stream-chat-android-compose/api/stream-chat-android-compose.api
@@ -751,6 +751,14 @@ public final class io/getstream/chat/android/compose/ui/channels/list/SearchResu
 	public static final fun SearchResultItem (Lio/getstream/chat/android/compose/state/channels/list/ItemState$SearchResultItemState;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V
 }
 
+public final class io/getstream/chat/android/compose/ui/chats/ChatListContentMode : java/lang/Enum {
+	public static final field Channels Lio/getstream/chat/android/compose/ui/chats/ChatListContentMode;
+	public static final field Threads Lio/getstream/chat/android/compose/ui/chats/ChatListContentMode;
+	public static fun getEntries ()Lkotlin/enums/EnumEntries;
+	public static fun valueOf (Ljava/lang/String;)Lio/getstream/chat/android/compose/ui/chats/ChatListContentMode;
+	public static fun values ()[Lio/getstream/chat/android/compose/ui/chats/ChatListContentMode;
+}
+
 public final class io/getstream/chat/android/compose/ui/chats/ChatMessageSelection : java/io/Serializable {
 	public static final field $stable I
 	public fun <init> ()V
@@ -770,7 +778,7 @@ public final class io/getstream/chat/android/compose/ui/chats/ChatMessageSelecti
 }
 
 public final class io/getstream/chat/android/compose/ui/chats/ChatsScreenKt {
-	public static final fun ChatsScreen (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigator;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory;Lio/getstream/chat/android/compose/viewmodel/threads/ThreadsViewModelFactory;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Lio/getstream/chat/android/compose/ui/channels/SearchMode;Lio/getstream/chat/android/compose/ui/chats/ListContentMode;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V
+	public static final fun ChatsScreen (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigator;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory;Lio/getstream/chat/android/compose/viewmodel/threads/ThreadsViewModelFactory;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Lio/getstream/chat/android/compose/ui/channels/SearchMode;Lio/getstream/chat/android/compose/ui/chats/ChatListContentMode;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V
 }
 
 public final class io/getstream/chat/android/compose/ui/chats/ComposableSingletons$ChatsScreenKt {
@@ -784,14 +792,6 @@ public final class io/getstream/chat/android/compose/ui/chats/ComposableSingleto
 	public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3;
 }
 
-public final class io/getstream/chat/android/compose/ui/chats/ListContentMode : java/lang/Enum {
-	public static final field Channels Lio/getstream/chat/android/compose/ui/chats/ListContentMode;
-	public static final field Threads Lio/getstream/chat/android/compose/ui/chats/ListContentMode;
-	public static fun getEntries ()Lkotlin/enums/EnumEntries;
-	public static fun valueOf (Ljava/lang/String;)Lio/getstream/chat/android/compose/ui/chats/ListContentMode;
-	public static fun values ()[Lio/getstream/chat/android/compose/ui/chats/ListContentMode;
-}
-
 public final class io/getstream/chat/android/compose/ui/components/BackButtonKt {
 	public static final fun BackButton (Landroidx/compose/ui/graphics/painter/Painter;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
 }
@@ -3877,8 +3877,8 @@ public final class io/getstream/chat/android/compose/ui/util/adaptivelayout/Thre
 	public synthetic fun <init> (Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public final fun canNavigateBack ()Z
 	public final fun navigateBack ()V
-	public final fun navigateTo (Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneDestination;Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneRole;)V
-	public static synthetic fun navigateTo$default (Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigator;Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneDestination;Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneRole;ILjava/lang/Object;)V
+	public final fun navigateTo (Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneDestination;ZLio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneRole;)V
+	public static synthetic fun navigateTo$default (Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigator;Lio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneDestination;ZLio/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneRole;ILjava/lang/Object;)V
 }
 
 public final class io/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigatorKt {
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ListContentMode.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ChatListContentMode.kt
similarity index 96%
rename from stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ListContentMode.kt
rename to stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ChatListContentMode.kt
index 5b423f1606d..05f9df50661 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ListContentMode.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ChatListContentMode.kt
@@ -22,7 +22,7 @@ import io.getstream.chat.android.core.ExperimentalStreamChatApi
  * The mode for displaying the list content in the chat screen.
  */
 @ExperimentalStreamChatApi
-public enum class ListContentMode {
+public enum class ChatListContentMode {
     /**
      * Display the list of channels.
      */
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ChatsScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ChatsScreen.kt
index b6fd7491a30..733bfa2de03 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ChatsScreen.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/chats/ChatsScreen.kt
@@ -76,6 +76,7 @@ import io.getstream.chat.android.core.ExperimentalStreamChatApi
 import io.getstream.chat.android.models.Channel
 import io.getstream.chat.android.models.User
 import io.getstream.chat.android.ui.common.state.messages.MessageMode
+import kotlin.math.abs
 
 /**
  * Represents a complete screen for chat, including a list of channels, threads, and messages.
@@ -94,7 +95,7 @@ import io.getstream.chat.android.ui.common.state.messages.MessageMode
  * `channelId`, `messageId`, and `parentMessageId` are `null`.
  * @param title The title displayed in the list pane top bar. Default is `"Stream Chat"`.
  * @param searchMode The current search mode. Default is [SearchMode.None].
- * @param listContentMode The mode for displaying the list content. Default is [ListContentMode.Channels].
+ * @param listContentMode The mode for displaying the list content. Default is [ChatListContentMode.Channels].
  * @param onBackPress Callback invoked when the user presses the back button.
  * @param onListTopBarAvatarClick Callback invoked when the user clicks on the avatar in the list pane top bar.
  * @param onListTopBarActionClick Callback invoked when the user clicks on the action icon in the list pane top bar.
@@ -117,7 +118,7 @@ public fun ChatsScreen(
     messagesViewModelFactoryProvider: MessagesViewModelFactoryProvider = DefaultMessagesViewModelFactoryProvider(),
     title: String = "Stream Chat",
     searchMode: SearchMode = SearchMode.None,
-    listContentMode: ListContentMode = ListContentMode.Channels,
+    listContentMode: ChatListContentMode = ChatListContentMode.Channels,
     onBackPress: () -> Unit = {},
     onListTopBarAvatarClick: (User?) -> Unit = {},
     onListTopBarActionClick: () -> Unit = {},
@@ -174,7 +175,7 @@ public fun ChatsScreen(
                     targetState = listContentMode,
                 ) { mode ->
                     when (mode) {
-                        ListContentMode.Channels -> {
+                        ChatListContentMode.Channels -> {
                             ChannelsScreen(
                                 viewModelFactory = channelViewModelFactory,
                                 isShowingHeader = false,
@@ -205,7 +206,7 @@ public fun ChatsScreen(
                             )
                         }
 
-                        ListContentMode.Threads -> {
+                        ChatListContentMode.Threads -> {
                             val viewModel = viewModel(
                                 modelClass = ThreadListViewModel::class.java,
                                 factory = threadsViewModelFactory,
@@ -263,12 +264,18 @@ public fun ChatsScreen(
         }
 
         LaunchedEffect(navigator.destinations) {
-            if (navigator.destinations.size > pagerDestinations.size) {
+            val diff = navigator.destinations.size - pagerDestinations.size
+            if (diff > 0) {
                 // When navigating forward, postpone the scroll to the last page until the new page is ready.
                 pagerDestinations = navigator.destinations
-            } else if (navigator.destinations.size < pagerDestinations.size) {
-                // When navigating back, scroll to the previous page before removing the last page.
-                pagerState.animateScrollToPage(pagerState.currentPage - 1)
+            } else if (diff < 0) {
+                if (abs(diff) > 1) {
+                    // When navigating back multiple pages, scroll directly to the new page then update destinations.
+                    pagerState.scrollToPage(pagerState.currentPage - 1)
+                } else {
+                    // When navigating back to the previous page, scroll then update destinations.
+                    pagerState.animateScrollToPage(pagerState.currentPage - 1)
+                }
                 pagerDestinations = navigator.destinations
             }
         }
@@ -376,7 +383,7 @@ public fun ChatsScreen(
 
 @Composable
 private fun AutoSelectFirstItem(
-    listContentMode: ListContentMode,
+    listContentMode: ChatListContentMode,
     channelViewModelFactory: ChannelViewModelFactory,
     threadsViewModelFactory: ThreadsViewModelFactory,
     navigator: ThreePaneNavigator,
@@ -384,7 +391,7 @@ private fun AutoSelectFirstItem(
 ) {
     val context = LocalContext.current
     when (listContentMode) {
-        ListContentMode.Channels -> {
+        ChatListContentMode.Channels -> {
             FirstChannelLoadHandler(channelViewModelFactory) { selection ->
                 navigator.initialDetailNavigation(messagesViewModelFactoryProvider, context) ?: run {
                     navigator.navigateTo(ThreePaneDestination(ThreePaneRole.Detail, selection))
@@ -392,7 +399,7 @@ private fun AutoSelectFirstItem(
             }
         }
 
-        ListContentMode.Threads -> {
+        ChatListContentMode.Threads -> {
             FirstThreadLoadHandler(threadsViewModelFactory) { selection ->
                 navigator.initialDetailNavigation(messagesViewModelFactoryProvider, context) ?: run {
                     navigator.navigateTo(ThreePaneDestination(ThreePaneRole.Detail, selection))
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigator.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigator.kt
index e68ca2496de..fe1db7cbda4 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigator.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigator.kt
@@ -36,17 +36,25 @@ public class ThreePaneNavigator(
     private val _destinations = mutableStateListOf(*destinations.toTypedArray())
     internal val destinations: List<ThreePaneDestination<*>> get() = _destinations.toList()
 
-    internal val current: ThreePaneDestination<*> get() = _destinations.last()
-
     /**
      * Navigates to the provided [destination].
      *
      * @param destination The destination to navigate to.
+     * @param replace Whether to replace an existing destination with a new one of the same pane *(if it exists)*,
+     * or add it to the stack.
      * @param popUpTo The role of the pane to pop up to before navigating to the destination.
      */
-    public fun navigateTo(destination: ThreePaneDestination<*>, popUpTo: ThreePaneRole? = null) {
+    public fun navigateTo(
+        destination: ThreePaneDestination<*>,
+        replace: Boolean = false,
+        popUpTo: ThreePaneRole? = null,
+    ) {
         popUpTo?.let(::popUpTo)
-        _destinations.add(destination)
+        if (replace) {
+            replace(destination)
+        } else {
+            _destinations.add(destination)
+        }
     }
 
     /**
@@ -67,6 +75,23 @@ public class ThreePaneNavigator(
         }
     }
 
+    /**
+     * Replace an existing destination with a new one of the same pane *(if it exists)*.
+     */
+    private fun replace(destination: ThreePaneDestination<*>) {
+        val operator = { existing: ThreePaneDestination<*> ->
+            if (existing.pane == destination.pane) {
+                destination
+            } else {
+                existing
+            }
+        }
+        val iterator = _destinations.listIterator()
+        while (iterator.hasNext()) {
+            iterator.set(operator(iterator.next()))
+        }
+    }
+
     internal companion object {
         val Saver: Saver<ThreePaneNavigator, Any> = listSaver(
             save = { navigator ->
diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigatorTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigatorTest.kt
new file mode 100644
index 00000000000..800b2610eb1
--- /dev/null
+++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/adaptivelayout/ThreePaneNavigatorTest.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.ui.util.adaptivelayout
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+internal class ThreePaneNavigatorTest {
+
+    @Test
+    fun `navigate to destination`() {
+        val navigator = ThreePaneNavigator()
+        val destination = ThreePaneDestination<Any>(ThreePaneRole.Detail)
+
+        navigator.navigateTo(destination)
+
+        assertEquals(2, navigator.destinations.size)
+        assertEquals(destination, navigator.destinations.last())
+    }
+
+    @Test
+    fun `navigate to destination with replace`() {
+        val navigator = ThreePaneNavigator()
+        val initialDestination = ThreePaneDestination<Any>(ThreePaneRole.Detail)
+        val newDestination = ThreePaneDestination<Any>(ThreePaneRole.Detail)
+
+        navigator.navigateTo(initialDestination)
+        navigator.navigateTo(newDestination, replace = true)
+
+        assertEquals(2, navigator.destinations.size)
+        assertEquals(newDestination, navigator.destinations.last())
+    }
+
+    @Test
+    fun `navigate to destination with pop up`() {
+        val navigator = ThreePaneNavigator()
+        val detailDestination = ThreePaneDestination<Any>(ThreePaneRole.Detail)
+        val infoDestination = ThreePaneDestination<Any>(ThreePaneRole.Info)
+
+        navigator.navigateTo(detailDestination)
+        navigator.navigateTo(infoDestination)
+        navigator.navigateTo(detailDestination, popUpTo = ThreePaneRole.List)
+
+        assertEquals(2, navigator.destinations.size)
+        assertEquals(detailDestination, navigator.destinations.last())
+    }
+
+    @Test
+    fun `can navigate back`() {
+        val navigator = ThreePaneNavigator()
+
+        assertFalse(navigator.canNavigateBack())
+
+        val destination = ThreePaneDestination<Any>(ThreePaneRole.Detail)
+        navigator.navigateTo(destination)
+
+        assertTrue(navigator.canNavigateBack())
+    }
+
+    @Test
+    fun `navigate back`() {
+        val navigator = ThreePaneNavigator()
+        val destination = ThreePaneDestination<Any>(ThreePaneRole.Detail)
+
+        navigator.navigateTo(destination)
+        navigator.navigateBack()
+
+        assertEquals(1, navigator.destinations.size)
+        assertEquals(ThreePaneRole.List, navigator.destinations.last().pane)
+    }
+
+    @Test
+    fun `pop up to`() {
+        val navigator = ThreePaneNavigator()
+        val detailDestination = ThreePaneDestination<Any>(ThreePaneRole.Detail)
+        val infoDestination = ThreePaneDestination<Any>(ThreePaneRole.Info)
+
+        navigator.navigateTo(detailDestination)
+        navigator.navigateTo(infoDestination)
+        navigator.popUpTo(ThreePaneRole.List)
+
+        assertEquals(1, navigator.destinations.size)
+        assertEquals(ThreePaneRole.List, navigator.destinations.last().pane)
+    }
+}


Reviews:
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T18:18:37Z
user: login: andremion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T10:21:31Z
user: login: VelikovPetar
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T11:46:36Z

Review comments:
diff_hunk: @@ -148,7 +152,7 @@ class ChatsActivity : BaseConnectedActivity() {
 
     @Composable
     private fun ScreenContent() {
-        var listContentMode by rememberSaveable { mutableStateOf(ListContentMode.Channels) }
+        var listContentMode by rememberSaveable { mutableStateOf(ChatListContentMode.Channels) }
path: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt
user: login: VelikovPetar
body: There are quite a lot of warnings in this class for using the @ExperimentalStreamApi - should we maybe OptIn? Or do you want to keep it like this on purpose, to emphasise that the api is still in progress?
created_at: 2025-03-20T18:15:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,7 +152,7 @@ class ChatsActivity : BaseConnectedActivity() {
 
     @Composable
     private fun ScreenContent() {
-        var listContentMode by rememberSaveable { mutableStateOf(ListContentMode.Channels) }
+        var listContentMode by rememberSaveable { mutableStateOf(ChatListContentMode.Channels) }
path: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt
user: login: andremion
body: Exactly. The goal is to emphasize that these components might still contain bugs or will be changed soon.
created_at: 2025-03-21T10:21:31Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-18T15:04:11Z
body: 
## SDK Size Comparison 📏

| SDK | Before | After | Difference | Status |
|-|-|-|-|-|
| stream-chat-android-client | 3.17 MB | 3.17 MB | 0.00 MB | 🟢 |
| stream-chat-android-offline | 3.38 MB | 3.38 MB | 0.00 MB | 🟢 |
| stream-chat-android-ui-components | 7.87 MB | 7.87 MB | 0.00 MB | 🟢 |
| stream-chat-android-compose | 9.89 MB | 9.89 MB | 0.00 MB | 🟢 |

author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-03-21T11:05:26Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=GetStream_stream-chat-android&pullRequest=5684) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [50.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=GetStream_stream-chat-android&pullRequest=5684&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=GetStream_stream-chat-android&pullRequest=5684)


author_association: NONE