Skip to content

Add GroupedQueryChannels and grouped unread counts#6437

Merged
VelikovPetar merged 67 commits into
v6from
feature/grouped-channels-endpoint
Jun 4, 2026
Merged

Add GroupedQueryChannels and grouped unread counts#6437
VelikovPetar merged 67 commits into
v6from
feature/grouped-channels-endpoint

Conversation

@VelikovPetar

@VelikovPetar VelikovPetar commented May 13, 2026

Copy link
Copy Markdown
Contributor

Goal

Add support for the server-driven grouped-channels API (POST /channels/grouped), where the backend partitions the channel list into named groups (e.g. direct, support) and returns per-group channels, pagination cursors, and unread counts. Surface those grouped unread counts on relevant chat events, and provide a Compose ChannelListViewModel path that drives a UI off a group key without the consumer needing to know about filter/sort.

Implementation

  • Endpoint: new ChatClient.queryGroupedChannels(limit, groups, watch, presence) returning GroupedChannels (per-group channels + unreadChannels + next/prev cursors). Per-group request options via GroupedChannelsGroupQuery. Backed by POST /channels/grouped (ChannelApi).
  • Plugin contract: new QueryGroupedChannelsListener; the StatePlugin implementation merges returned per-group unread counts into GlobalState.groupedUnreadChannels and routes each returned group into a state keyed by a new sealed QueryChannelsIdentifier:
    • QueryChannelsIdentifier.Standard(filter, sort) — existing offset-paginated path
    • QueryChannelsIdentifier.Grouped(groupKey) — new cursor-paginated path
  • Logic: QueryChannelsLogic branches on identifier. applyGroupedResult replaces channels on the first page (resetting channelsOffset defensively to keep the Standard offset paginator from picking up stale state), appends on subsequent pages (driven off the request's next cursor), and persists per-group state under a groupKey-derived DB key.
  • Events: new HasGroupedUnreadChannels marker on NewMessageEvent, NotificationMessageNewEvent, NotificationMarkReadEvent, NotificationMarkUnreadEvent, NotificationChannelDeletedEvent, NotificationChannelTruncatedEvent. EventHandlerSequential updates GlobalState.groupedUnreadChannels whenever an inbound event carries the map. GroupedUnreadChannelsUpdater is the single calculator: events with a non-null map replace the current state, channel.updated/channel.updated_by_user events migrate per-group counts when the channel's group field changes, and queryGroupedChannels results merge per-group counts.
  • Group-aware event routing: new GroupAwareChatEventHandler classifies channel-bearing events using a pluggable ChannelGroupResolver. The default resolver reads channel.extraData["group"] and always includes an "all" sentinel. Channels are routed Add/Remove/Skip per inbound group. The LogicRegistry auto-install of the default factory is idempotent — it won't clobber a factory another caller has already installed on the state. Member/CID events delegate to DefaultChatEventHandler unchanged.
  • Compose: new ChannelListViewModel(chatClient, groupKey, ...) constructor + matching ChannelViewModelFactory(chatClient, groupKey, ...). Wires the VM to the identifier-keyed state via initGroupedQueryChannelsAsState, with a group-aware event handler factory keyed on groupKey. Pagination uses cursor-based queryGroupedChannels(groups = mapOf(groupKey to GroupedChannelsGroupQuery(next = cursor))). The Standard path is untouched.
  • Sync/recovery: SyncManager.restoreActiveChannels() splits standard vs grouped reconnect paths. Grouped queries are refreshed via a single queryGroupedChannels() call; manually-watched channels are re-watched via WatchedChannelRecord/WatchedChannelStateFlow (weak-referenced from StateRegistry). Recovery assumes all active grouped queries share the same request-level limit/watch/presence flags — the first captured config wins.
  • QueryChannelsSpec: new optional groupKey field for grouped identity. cids remains a mutable var for backward compatibility with prior versions; the two-arg constructor and 2-arg copy are preserved for source/binary compat.
  • DB: schema bumped to 99 for the new groupKey column on QueryChannelsEntity. Uses the existing fallbackToDestructiveMigration strategy.

Testing

Unit-test coverage added for each layer:

  • Endpoint dispatch + plugin notification: ChatClientGroupedChannelsApiTests
  • Moshi serialization (request/response): MoshiChatApiTest, QueryGroupedChannelsResponseAdapterTest
  • Event mapping (new grouped_unread_channels field): EventMappingTestArguments
  • Group-aware event routing: GroupAwareChatEventHandlerTest, DefaultChannelGroupResolverTest
  • Grouped unread counts calculator: GroupedUnreadChannelsUpdaterTest
  • Listener state merge / first-page vs paginated detection / failure path: QueryGroupedChannelsListenerStateTest
  • Sync recovery split: SyncManagerTest
  • Identifier-keyed state registry: StateRegistryTest, QueryChannelsMutableStateTest
  • Logic registry Grouped identifier handling (creation, idempotent retrieval, auto-installed factory): LogicRegistryTest
  • QueryChannelsLogic grouped behavior: QueryChannelsLogicGroupedTest covers applyGroupedResult (first-page replace, subsequent-page append, cursor/end-of-channels, DB persistence, defensive channelsOffset reset, no-op on Standard) and loadOfflineGroupedChannels (cache load, race-condition guard, null cache, no-op on Standard)
  • Compose grouped init: ChatClientStateCallsTest
  • Global state groupedUnreadChannels propagation: EventHandlerSequentialTest

Manually verified the Compose sample app in both Standard and Grouped modes: initial render, cursor pagination, event-driven Add/Remove/Skip across groups, reconnect/recovery, and grouped unread counts updating from inbound events.

Patch for testing
Subject: [PATCH] Prevent double updates in GroupedUnreadChannelsUpdater.kt.
---
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/LeaveChannelBottomSheet.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/LeaveChannelBottomSheet.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/LeaveChannelBottomSheet.kt
new file mode 100644
--- /dev/null	(date 1780407669798)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/LeaveChannelBottomSheet.kt	(date 1780407669798)
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.getstream.chat.android.models.Channel
+import kotlinx.coroutines.launch
+
+/**
+ * Bottom sheet shown when the user long-presses a channel item. Exposes destructive channel
+ * actions (currently: leave + freeze) for the given [channel].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun LeaveChannelBottomSheet(
+    channel: Channel,
+    onDismiss: () -> Unit,
+    onLeaveAndFreeze: (Channel) -> Unit,
+) {
+    val sheetState = rememberModalBottomSheetState()
+    val scope = rememberCoroutineScope()
+
+    ModalBottomSheet(
+        onDismissRequest = onDismiss,
+        sheetState = sheetState,
+    ) {
+        Column(modifier = Modifier.navigationBarsPadding()) {
+            Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+                Text(
+                    text = channel.name.ifBlank { channel.cid },
+                    style = MaterialTheme.typography.titleMedium,
+                    fontWeight = FontWeight.SemiBold,
+                    color = MaterialTheme.colorScheme.onSurface,
+                )
+                Spacer(Modifier.height(2.dp))
+                Text(
+                    text = "Channel actions",
+                    style = MaterialTheme.typography.bodySmall,
+                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+                )
+            }
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .clickable {
+                        scope.launch { sheetState.hide() }
+                            .invokeOnCompletion {
+                                onLeaveAndFreeze(channel)
+                                onDismiss()
+                            }
+                    }
+                    .padding(horizontal = 16.dp, vertical = 16.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                Icon(
+                    imageVector = Icons.AutoMirrored.Filled.Logout,
+                    contentDescription = null,
+                    tint = MaterialTheme.colorScheme.error,
+                )
+                Spacer(Modifier.size(16.dp))
+                Text(
+                    text = "Leave and freeze channel",
+                    style = MaterialTheme.typography.bodyLarge,
+                    color = MaterialTheme.colorScheme.error,
+                )
+            }
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/MainActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/MainActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/MainActivity.kt
new file mode 100644
--- /dev/null	(date 1780407722526)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/MainActivity.kt	(date 1780407722526)
@@ -0,0 +1,395 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.ViewModelProvider
+import io.getstream.chat.android.client.ChatClient
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.ChannelGroupMenu
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.CreateChannelMenu
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.LeaveChannelBottomSheet
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.theme.GroupedChannelsSampleTheme
+import io.getstream.chat.android.compose.ui.channels.list.ChannelItem
+import io.getstream.chat.android.compose.ui.channels.list.ChannelList
+import io.getstream.chat.android.compose.ui.theme.ChatTheme
+import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel
+import io.getstream.chat.android.compose.viewmodel.channels.ChannelViewModelFactory
+import io.getstream.chat.android.models.Channel
+import io.getstream.chat.android.state.extensions.globalStateFlow
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flatMapLatest
+
+class MainActivity : ComponentActivity() {
+
+    private val allFactory by lazy {
+        ChannelViewModelFactory(groupKey = ChannelGroup.ALL.key)
+    }
+    private val newFactory by lazy {
+        ChannelViewModelFactory(groupKey = ChannelGroup.NEW.key)
+    }
+    private val currentFactory by lazy {
+        ChannelViewModelFactory(groupKey = ChannelGroup.CURRENT.key)
+    }
+    private val oldFactory by lazy {
+        ChannelViewModelFactory(groupKey = ChannelGroup.OLD.key)
+    }
+
+    private val allViewModel: ChannelListViewModel by lazy {
+        ViewModelProvider(this, allFactory)[ChannelGroup.ALL.key, ChannelListViewModel::class.java]
+    }
+    private val newViewModel: ChannelListViewModel by lazy {
+        ViewModelProvider(this, newFactory)[ChannelGroup.NEW.key, ChannelListViewModel::class.java]
+    }
+    private val currentViewModel: ChannelListViewModel by lazy {
+        ViewModelProvider(this, currentFactory)[ChannelGroup.CURRENT.key, ChannelListViewModel::class.java]
+    }
+    private val oldViewModel: ChannelListViewModel by lazy {
+        ViewModelProvider(this, oldFactory)[ChannelGroup.OLD.key, ChannelListViewModel::class.java]
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enableEdgeToEdge()
+
+        ChatManager.prefillGroupedChannels()
+
+        setContent {
+            GroupedChannelsSampleTheme {
+                var selected by rememberSaveable { mutableStateOf(ChannelGroup.ALL) }
+
+                @OptIn(ExperimentalCoroutinesApi::class)
+                val unreadByTab by remember {
+                    ChatClient.instance()
+                        .globalStateFlow
+                        .flatMapLatest { it.groupedUnreadChannels }
+                }.collectAsState(initial = emptyMap())
+
+                Box(
+                    Modifier
+                        .fillMaxSize()
+                        .background(MaterialTheme.colorScheme.background),
+                ) {
+                    Column(Modifier.fillMaxSize()) {
+                        val currentUser by remember {
+                            ChatClient.instance().clientState.user
+                        }.collectAsState(initial = null)
+                        val availableUsers = remember(currentUser) {
+                            LoginUser.all.filter { it.id != currentUser?.id }
+                        }
+                        TopBar(
+                            title = "Grouped Channels",
+                            onMarkAllRead = ChatManager::markAllRead,
+                            availableUsers = availableUsers,
+                            onCreateChannelWith = ChatManager::createChannelWith,
+                        )
+
+                        val vm = when (selected) {
+                            ChannelGroup.ALL -> allViewModel
+                            ChannelGroup.NEW -> newViewModel
+                            ChannelGroup.CURRENT -> currentViewModel
+                            ChannelGroup.OLD -> oldViewModel
+                        }
+                        Box(modifier = Modifier.weight(1f)) {
+                            ChatTheme {
+                                key(selected) {
+                                    val openChannel: (Channel) -> Unit = { channel ->
+                                        startActivity(
+                                            ChannelActivity.createIntent(
+                                                this@MainActivity,
+                                                channel.cid,
+                                            ),
+                                        )
+                                    }
+                                    var leaveSheetChannel by remember { mutableStateOf<Channel?>(null) }
+                                    ChannelList(
+                                        modifier = Modifier.fillMaxSize(),
+                                        viewModel = vm,
+                                        onChannelClick = openChannel,
+                                        channelContent = { itemState ->
+                                            val user by vm.user.collectAsState()
+                                            ChannelItem(
+                                                modifier = Modifier.animateItem(),
+                                                channelItem = itemState,
+                                                currentUser = user,
+                                                onChannelClick = openChannel,
+                                                onChannelLongClick = { leaveSheetChannel = it },
+                                                trailingContent = { state ->
+                                                    with(ChatTheme.componentFactory) {
+                                                        ChannelItemTrailingContent(
+                                                            channelItem = state,
+                                                            currentUser = user,
+                                                        )
+                                                    }
+                                                    ChannelGroupMenu(
+                                                        channel = state.channel,
+                                                        onMoveTo = { group ->
+                                                            ChatManager.moveChannelToGroup(
+                                                                state.channel,
+                                                                group.key,
+                                                            )
+                                                        },
+                                                    )
+                                                },
+                                            )
+                                        },
+                                    )
+                                    leaveSheetChannel?.let { channel ->
+                                        LeaveChannelBottomSheet(
+                                            channel = channel,
+                                            onDismiss = { leaveSheetChannel = null },
+                                            onLeaveAndFreeze = ChatManager::leaveExpireAndFreezeChannel,
+                                        )
+                                    }
+                                }
+                            }
+                        }
+
+                        BottomTabBar(
+                            tabs = ChannelGroup.entries,
+                            selected = selected,
+                            unreadByTab = unreadByTab,
+                            onSelect = { selected = it },
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+private val UnreadBadgeColor = Color(0xFFFF3B30)
+
+// region Composables
+
+@Composable
+private fun TopBar(
+    title: String,
+    onMarkAllRead: () -> Unit,
+    availableUsers: List<LoginUser>,
+    onCreateChannelWith: (LoginUser) -> Unit,
+) {
+    var createMenuExpanded by remember { mutableStateOf(false) }
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .statusBarsPadding()
+            .padding(horizontal = 16.dp, vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Text(
+            text = title,
+            modifier = Modifier.weight(1f),
+            fontSize = 22.sp,
+            fontWeight = FontWeight.Bold,
+            color = MaterialTheme.colorScheme.onBackground,
+        )
+        Surface(
+            shape = RoundedCornerShape(50),
+            color = MaterialTheme.colorScheme.surface,
+            shadowElevation = 2.dp,
+        ) {
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                IconButton(onClick = onMarkAllRead) {
+                    Icon(
+                        imageVector = Icons.Filled.CheckCircle,
+                        contentDescription = "Mark all read",
+                        tint = MaterialTheme.colorScheme.onSurface,
+                    )
+                }
+                Box {
+                    IconButton(
+                        onClick = { createMenuExpanded = true },
+                        enabled = availableUsers.isNotEmpty(),
+                    ) {
+                        Icon(
+                            imageVector = Icons.Filled.Add,
+                            contentDescription = "Create new channel",
+                            tint = if (availableUsers.isNotEmpty()) {
+                                MaterialTheme.colorScheme.onSurface
+                            } else {
+                                MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)
+                            },
+                        )
+                    }
+                    CreateChannelMenu(
+                        expanded = createMenuExpanded,
+                        users = availableUsers,
+                        onDismiss = { createMenuExpanded = false },
+                        onUserSelected = {
+                            createMenuExpanded = false
+                            onCreateChannelWith(it)
+                        },
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun BottomTabBar(
+    tabs: List<ChannelGroup>,
+    selected: ChannelGroup,
+    unreadByTab: Map<String, Int>,
+    onSelect: (ChannelGroup) -> Unit,
+) {
+    Surface(
+        modifier = Modifier.fillMaxWidth(),
+        color = MaterialTheme.colorScheme.surface,
+        shadowElevation = 8.dp,
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .navigationBarsPadding()
+                .padding(horizontal = 8.dp, vertical = 8.dp),
+            horizontalArrangement = Arrangement.SpaceEvenly,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            tabs.forEach { tab ->
+                BottomTabItem(
+                    tab = tab,
+                    selected = tab == selected,
+                    unread = unreadByTab[tab.key] ?: 0,
+                    onClick = { onSelect(tab) },
+                    modifier = Modifier.weight(1f),
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun BottomTabItem(
+    tab: ChannelGroup,
+    selected: Boolean,
+    unread: Int,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val containerColor by animateColorAsState(
+        targetValue = if (selected) {
+            MaterialTheme.colorScheme.primaryContainer
+        } else {
+            Color.Transparent
+        },
+        label = "tab-bg",
+    )
+    val contentColor by animateColorAsState(
+        targetValue = if (selected) {
+            MaterialTheme.colorScheme.primary
+        } else {
+            MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+        },
+        label = "tab-fg",
+    )
+
+    Box(
+        modifier = modifier
+            .clickable(onClick = onClick)
+            .padding(vertical = 4.dp),
+        contentAlignment = Alignment.Center,
+    ) {
+        Column(horizontalAlignment = Alignment.CenterHorizontally) {
+            Surface(
+                shape = RoundedCornerShape(50),
+                color = containerColor,
+            ) {
+                Box(
+                    modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    BadgedBox(
+                        badge = {
+                            if (unread > 0) {
+                                Badge(
+                                    containerColor = UnreadBadgeColor,
+                                    contentColor = Color.White,
+                                ) {
+                                    Text(
+                                        text = if (unread > 99) "99+" else unread.toString(),
+                                        fontSize = 10.sp,
+                                        fontWeight = FontWeight.Bold,
+                                    )
+                                }
+                            }
+                        },
+                    ) {
+                        Icon(
+                            imageVector = tab.icon,
+                            contentDescription = tab.label,
+                            tint = contentColor,
+                            modifier = Modifier.size(22.dp),
+                        )
+                    }
+                }
+            }
+            Spacer(Modifier.height(4.dp))
+            Text(
+                text = tab.label,
+                fontSize = 12.sp,
+                fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
+                color = contentColor,
+            )
+        }
+    }
+}
+
+// endregion
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/ChannelGroupMenu.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/ChannelGroupMenu.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/ChannelGroupMenu.kt
new file mode 100644
--- /dev/null	(date 1780407218873)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/ChannelGroupMenu.kt	(date 1780407218873)
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.outlined.GridView
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ChannelGroup
+import io.getstream.chat.android.models.Channel
+import io.getstream.chat.android.models.ChannelCapabilities
+
+/**
+ * Trailing-content icon button on a channel item that opens a dropdown for moving the channel to
+ * a different group. The button is disabled (and dimmed) when the current user lacks the
+ * `update-channel` capability.
+ */
+@Composable
+internal fun ChannelGroupMenu(
+    channel: Channel,
+    onMoveTo: (ChannelGroup) -> Unit,
+) {
+    var expanded by rememberSaveable(channel.cid) { mutableStateOf(false) }
+    val canUpdate = ChannelCapabilities.UPDATE_CHANNEL in channel.ownCapabilities
+    val currentGroup = channel.extraData["group"] as? String
+
+    Box {
+        IconButton(
+            onClick = { expanded = true },
+            enabled = canUpdate,
+        ) {
+            Icon(
+                imageVector = Icons.Outlined.GridView,
+                contentDescription = "Move to group",
+                tint = if (canUpdate) {
+                    MaterialTheme.colorScheme.primary
+                } else {
+                    MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)
+                },
+            )
+        }
+        DropdownMenu(
+            expanded = expanded,
+            onDismissRequest = { expanded = false },
+        ) {
+            Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+                Text(
+                    text = "Move to group",
+                    style = MaterialTheme.typography.titleSmall,
+                    fontWeight = FontWeight.SemiBold,
+                    color = MaterialTheme.colorScheme.onSurface,
+                )
+                Spacer(Modifier.height(2.dp))
+                Text(
+                    text = "Pick which group this channel belongs to",
+                    style = MaterialTheme.typography.bodySmall,
+                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+                )
+            }
+            HorizontalDivider()
+            ChannelGroup.entries
+                .filter { it != ChannelGroup.ALL }
+                .forEach { group ->
+                    DropdownMenuItem(
+                        text = { Text(group.label) },
+                        leadingIcon = {
+                            if (group.key == currentGroup) {
+                                Icon(
+                                    imageVector = Icons.Filled.Check,
+                                    contentDescription = null,
+                                )
+                            } else {
+                                Spacer(Modifier.size(24.dp))
+                            }
+                        },
+                        onClick = {
+                            expanded = false
+                            onMoveTo(group)
+                        },
+                    )
+                }
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/Avatar.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/Avatar.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/Avatar.kt
new file mode 100644
--- /dev/null	(date 1780407218855)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/Avatar.kt	(date 1780407218855)
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun Avatar(
+    seed: String,
+    initials: String,
+    modifier: Modifier = Modifier,
+    fontSize: TextUnit = 16.sp,
+) {
+    val palette = remember(seed) { avatarGradient(seed) }
+    Box(
+        modifier = modifier.background(brush = palette, shape = CircleShape),
+        contentAlignment = Alignment.Center,
+    ) {
+        Text(
+            text = initials,
+            color = Color.White,
+            fontSize = fontSize,
+            fontWeight = FontWeight.Bold,
+        )
+    }
+}
+
+private val AvatarPalettes: List<Pair<Color, Color>> = listOf(
+    Color(0xFF7F7FD5) to Color(0xFF86A8E7),
+    Color(0xFFFF6A88) to Color(0xFFFFB199),
+    Color(0xFF11998E) to Color(0xFF38EF7D),
+    Color(0xFFFF9A9E) to Color(0xFFFAD0C4),
+    Color(0xFF4776E6) to Color(0xFF8E54E9),
+    Color(0xFFF7971E) to Color(0xFFFFD200),
+)
+
+private fun avatarGradient(seed: String): Brush {
+    val (start, end) = AvatarPalettes[(seed.hashCode().toUInt() % AvatarPalettes.size.toUInt()).toInt()]
+    return Brush.linearGradient(listOf(start, end))
+}
+
+fun String.initials(): String {
+    val parts = split('_', '-', ' ').filter { it.isNotBlank() }
+    return when {
+        parts.isEmpty() -> "?"
+        parts.size == 1 -> parts[0].take(2).uppercase()
+        else -> (parts[0].take(1) + parts[1].take(1)).uppercase()
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelActivity.kt
new file mode 100644
--- /dev/null	(date 1780407218884)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelActivity.kt	(date 1780407218884)
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import io.getstream.chat.android.compose.ui.messages.MessagesScreen
+import io.getstream.chat.android.compose.ui.theme.ChatTheme
+import io.getstream.chat.android.compose.viewmodel.messages.MessagesViewModelFactory
+
+/**
+ * Minimum-feature Channel screen.
+ */
+class ChannelActivity : ComponentActivity() {
+
+    private val cid: String by lazy {
+        requireNotNull(intent.getStringExtra(KEY_CHANNEL_ID)) { "Channel ID must be provided" }
+    }
+
+    private val factory by lazy {
+        MessagesViewModelFactory(
+            context = this,
+            channelId = cid,
+        )
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContent {
+            ChatTheme {
+                MessagesScreen(
+                    viewModelFactory = factory,
+                    onBackPressed = { finish() },
+                )
+            }
+        }
+    }
+
+    companion object {
+        private const val KEY_CHANNEL_ID = "channelId"
+
+        fun createIntent(context: Context, channelId: String): Intent {
+            return Intent(context, ChannelActivity::class.java).apply {
+                putExtra(KEY_CHANNEL_ID, channelId)
+            }
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/CreateChannelMenu.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/CreateChannelMenu.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/CreateChannelMenu.kt
new file mode 100644
--- /dev/null	(date 1780407218878)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/CreateChannelMenu.kt	(date 1780407218878)
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels.ui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.LoginUser
+
+/**
+ * Dropdown anchored to the "+" action in the top bar that lets the user pick a peer to start
+ * a 1:1 channel with. The current user is expected to be filtered out of [users] by the caller.
+ */
+@Composable
+internal fun CreateChannelMenu(
+    expanded: Boolean,
+    users: List<LoginUser>,
+    onDismiss: () -> Unit,
+    onUserSelected: (LoginUser) -> Unit,
+) {
+    DropdownMenu(
+        expanded = expanded,
+        onDismissRequest = onDismiss,
+    ) {
+        Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+            Text(
+                text = "New channel",
+                style = MaterialTheme.typography.titleSmall,
+                fontWeight = FontWeight.SemiBold,
+                color = MaterialTheme.colorScheme.onSurface,
+            )
+            Spacer(Modifier.height(2.dp))
+            Text(
+                text = "Pick a user to start a 1:1 channel with",
+                style = MaterialTheme.typography.bodySmall,
+                color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+            )
+        }
+        HorizontalDivider()
+        users.forEach { user ->
+            DropdownMenuItem(
+                text = { Text(user.name) },
+                leadingIcon = {
+                    Avatar(
+                        seed = user.id,
+                        initials = user.name.initials(),
+                        modifier = Modifier.size(28.dp),
+                        fontSize = 11.sp,
+                    )
+                },
+                onClick = { onUserSelected(user) },
+            )
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChatManager.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChatManager.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChatManager.kt
new file mode 100644
--- /dev/null	(date 1780409537766)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChatManager.kt	(date 1780409537766)
@@ -0,0 +1,186 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels
+
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import io.getstream.chat.android.client.ChatClient
+import io.getstream.chat.android.client.logger.ChatLogLevel
+import io.getstream.chat.android.models.Channel
+import io.getstream.chat.android.models.User
+import io.getstream.chat.android.offline.plugin.factory.StreamOfflinePluginFactory
+import io.getstream.chat.android.state.plugin.config.StatePluginConfig
+import io.getstream.chat.android.state.plugin.factory.StreamStatePluginFactory
+import io.getstream.result.call.enqueue
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+
+/**
+ * Handles ChatClient initialization, connection, and the channel operations used by the sample.
+ */
+object ChatManager {
+
+    private const val TAG = "ChatManager"
+    private const val API_KEY = "vrvdwv6pk4yz"
+
+    /**
+     * Initializes the ChatClient with offline and state plugins, then connects the user.
+     */
+    fun initializeAndConnect(
+        appContext: Context,
+        loginUser: LoginUser,
+        onComplete: () -> Unit,
+        onError: () -> Unit,
+    ) {
+        val state = StreamStatePluginFactory(
+            config = StatePluginConfig(),
+            appContext = appContext,
+        )
+        val offline = StreamOfflinePluginFactory(
+            appContext = appContext,
+        )
+        val chatClient = ChatClient.Builder(API_KEY, appContext)
+            .withPlugins(state, offline)
+            .logLevel(ChatLogLevel.ALL)
+            .build()
+        chatClient.connectUser(
+            user = User(id = loginUser.id, name = loginUser.name),
+            token = loginUser.token,
+        ).enqueue(
+            onSuccess = { onComplete() },
+            onError = { onError() },
+        )
+    }
+
+    /**
+     * Prefills the local state/db with grouped channels for the current user.
+     */
+    fun prefillGroupedChannels() {
+        ChatClient.instance()
+            .queryGroupedChannels(
+                groups = ChannelGroup.entries.map { it.name.lowercase() },
+                watch = true,
+            )
+            .enqueue(
+                onSuccess = { grouped ->
+                    // No action needed, state/db is prefilled automatically
+                    Log.d(TAG, "Prefill grouped channels: ${grouped.groups.keys}")
+                },
+                onError = {
+                    Log.e(TAG, "Failed to query grouped channels for prefill")
+                },
+            )
+    }
+
+    /**
+     * Marks all channels as read for the current user.
+     */
+    fun markAllRead() {
+        ChatClient.instance()
+            .markAllRead()
+            .enqueue(
+                onSuccess = {
+                    Log.d(TAG, "Marked all channels as read")
+                },
+                onError = {
+                    Log.e(TAG, "Failed to mark all channels as read")
+                },
+            )
+    }
+
+    /**
+     * Creates a new 1:1 channel between the current user and [otherUser], starting in the "new" group.
+     */
+    fun createChannelWith(otherUser: LoginUser) {
+        val client = ChatClient.instance()
+        val currentUserId = client.getCurrentUser()?.id ?: return
+        val id = "new-channel-${System.currentTimeMillis()}"
+        val name = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            "New Channel ${DateTimeFormatter.ISO_LOCAL_TIME.format(LocalTime.now())}"
+        } else {
+            "New Channel ${System.currentTimeMillis()}"
+        }
+        client.channel("messaging", id)
+            .create(
+                memberIds = listOf(currentUserId, otherUser.id),
+                extraData = mapOf(
+                    "name" to name,
+                    "group" to "new",
+                ),
+            ).enqueue(
+                onSuccess = { channel ->
+                    Log.d(TAG, "Created channel ${channel.cid} with ${otherUser.id}")
+                },
+                onError = {
+                    Log.e(TAG, "Failed to create channel: $it")
+                },
+            )
+    }
+
+    /**
+     * Moves [channel] to the given [groupKey] via a partial channel update.
+     */
+    fun moveChannelToGroup(channel: Channel, groupKey: String) {
+        ChatClient.instance()
+            .updateChannelPartial(
+                channelType = channel.type,
+                channelId = channel.id,
+                set = mapOf("group" to groupKey),
+                unset = emptyList(),
+            )
+            .enqueue(
+                onSuccess = {
+                    Log.d(TAG, "Channel ${channel.cid} moved to '$groupKey'")
+                },
+                onError = {
+                    Log.e(TAG, "Failed to move channel ${channel.cid}: $it")
+                },
+            )
+    }
+
+    fun leaveExpireAndFreezeChannel(channel: Channel) {
+        val client = ChatClient.instance()
+        val currentUserId = client.getCurrentUser()?.id ?: ""
+        val channelClient = client.channel(channel.cid)
+
+        val expireAndFreezeChannel: () -> Unit =  {
+            channelClient
+                .updatePartial(set = mapOf("group" to "old"))
+                .enqueue(
+                    onSuccess = {
+                        Log.d("X_PETAR", "successfully p.updated channel")
+                    },
+                    onError = {
+                        Log.d("X_PETAR", "failed to p.update channel $it")
+                    }
+                )
+        }
+
+        expireAndFreezeChannel()
+        channelClient.removeMembers(listOf(currentUserId))
+            .enqueue(
+                onSuccess = {
+                    Log.d("X_PETAR", "successfully left channel")
+                    // expireAndFreezeChannel()
+                },
+                onError = {
+                    Log.d("X_PETAR", "failed to leave channel")
+                }
+            )
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelGroup.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelGroup.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelGroup.kt
new file mode 100644
--- /dev/null	(date 1780407218889)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ChannelGroup.kt	(date 1780407218889)
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Chat
+import androidx.compose.material.icons.filled.Archive
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material.icons.filled.Inbox
+import androidx.compose.ui.graphics.vector.ImageVector
+
+/**
+ * The buckets a channel can be sorted into in the sample app.
+ *
+ * The [key] is the value stored in the channel's `extraData["group"]` and used by the backend's
+ * `queryGroupedChannels` endpoint.
+ */
+internal enum class ChannelGroup(
+    val key: String,
+    val label: String,
+    val icon: ImageVector,
+) {
+    ALL("all", "All", Icons.Filled.Inbox),
+    NEW("new", "New", Icons.Filled.AutoAwesome),
+    CURRENT("current", "Current", Icons.AutoMirrored.Filled.Chat),
+    OLD("old", "Old", Icons.Filled.Archive),
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginUser.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginUser.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginUser.kt
new file mode 100644
--- /dev/null	(date 1780407219223)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginUser.kt	(date 1780407219223)
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels
+
+/**
+ * A user that can be selected on the login screen.
+ */
+data class LoginUser(
+    val id: String,
+    val name: String,
+    val token: String,
+) {
+    companion object {
+        val member01 = LoginUser(
+            id = "member_01",
+            name = "member_01",
+            token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibWVtYmVyXzAxIn0.JEXL5-mvLcz96EG-CUSbdYgY-hex3iqktL75uSi_Uoo",
+        )
+
+        val member02 = LoginUser(
+            id = "member_02",
+            name = "member_02",
+            token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibWVtYmVyXzAyIn0.z9gG7F9u-td_It3WA2kGOkI_Li5TtrcFh3YAi4AxgT0",
+        )
+
+        val member03 = LoginUser(
+            id = "member_03",
+            name = "member_03",
+            token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibWVtYmVyXzAzIn0.G5e_HucwuVmWKB6NjuE-izAltTxH_k-AyY5RlAo-2VY",
+        )
+
+        val all: List<LoginUser> = listOf(member01, member02, member03)
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginActivity.kt
new file mode 100644
--- /dev/null	(date 1780407219214)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/LoginActivity.kt	(date 1780407219214)
@@ -0,0 +1,318 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.Avatar
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.initials
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.theme.GroupedChannelsSampleTheme
+import kotlinx.coroutines.launch
+
+class LoginActivity : ComponentActivity() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enableEdgeToEdge()
+        setContent {
+            GroupedChannelsSampleTheme {
+                LoginScreen(
+                    users = LoginUser.all,
+                    onLoginSuccess = {
+                        startActivity(Intent(this, MainActivity::class.java))
+                        finish()
+                    },
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun LoginScreen(
+    users: List<LoginUser>,
+    onLoginSuccess: () -> Unit,
+) {
+    val context = LocalContext.current
+    val scope = rememberCoroutineScope()
+    val snackbarHostState = remember { SnackbarHostState() }
+
+    var selected by remember { mutableStateOf(users.firstOrNull()) }
+    var connecting by remember { mutableStateOf(false) }
+
+    Scaffold(
+        snackbarHost = { SnackbarHost(snackbarHostState) },
+        containerColor = MaterialTheme.colorScheme.background,
+    ) { padding ->
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(padding)
+                .background(
+                    Brush.verticalGradient(
+                        colors = listOf(
+                            MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f),
+                            MaterialTheme.colorScheme.background,
+                        ),
+                    ),
+                )
+                .systemBarsPadding(),
+        ) {
+            Column(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(horizontal = 24.dp),
+                horizontalAlignment = Alignment.CenterHorizontally,
+            ) {
+                Spacer(Modifier.height(48.dp))
+                Text(
+                    text = "Stream Chat",
+                    fontSize = 32.sp,
+                    fontWeight = FontWeight.Bold,
+                    color = MaterialTheme.colorScheme.onBackground,
+                )
+                Spacer(Modifier.height(8.dp))
+                Text(
+                    text = "Choose a user to continue",
+                    fontSize = 16.sp,
+                    color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
+                )
+                Spacer(Modifier.height(32.dp))
+
+                LazyColumn(
+                    modifier = Modifier
+                        .weight(1f)
+                        .fillMaxWidth(),
+                    verticalArrangement = Arrangement.spacedBy(12.dp),
+                    contentPadding = PaddingValues(vertical = 4.dp),
+                ) {
+                    items(users, key = { it.id }) { user ->
+                        UserCard(
+                            user = user,
+                            selected = selected?.id == user.id,
+                            enabled = !connecting,
+                            onClick = { selected = user },
+                        )
+                    }
+                }
+
+                Spacer(Modifier.height(16.dp))
+
+                Button(
+                    onClick = {
+                        val user = selected ?: return@Button
+                        connecting = true
+                        ChatManager.initializeAndConnect(
+                            appContext = context.applicationContext,
+                            loginUser = user,
+                            onComplete = {
+                                connecting = false
+                                onLoginSuccess()
+                            },
+                            onError = {
+                                connecting = false
+                                scope.launch {
+                                    snackbarHostState.showSnackbar(
+                                        "Failed to connect as ${user.name}. Please try again.",
+                                    )
+                                }
+                            },
+                        )
+                    },
+                    enabled = selected != null && !connecting,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .height(56.dp),
+                    shape = RoundedCornerShape(16.dp),
+                    colors = ButtonDefaults.buttonColors(
+                        containerColor = MaterialTheme.colorScheme.primary,
+                    ),
+                ) {
+                    if (connecting) {
+                        CircularProgressIndicator(
+                            modifier = Modifier.size(22.dp),
+                            color = MaterialTheme.colorScheme.onPrimary,
+                            strokeWidth = 2.dp,
+                        )
+                    } else {
+                        Text(
+                            text = "Continue",
+                            fontSize = 16.sp,
+                            fontWeight = FontWeight.SemiBold,
+                        )
+                    }
+                }
+
+                Spacer(Modifier.height(24.dp))
+            }
+
+            // Subtle overlay during connection to block taps
+            AnimatedVisibility(
+                visible = connecting,
+                enter = fadeIn(),
+                exit = fadeOut(),
+            ) {
+                Box(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .background(Color.Black.copy(alpha = 0.05f)),
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun UserCard(
+    user: LoginUser,
+    selected: Boolean,
+    enabled: Boolean,
+    onClick: () -> Unit,
+) {
+    val elevation by animateDpAsState(
+        targetValue = if (selected) 6.dp else 1.dp,
+        label = "card-elevation",
+    )
+    val borderWidth by animateDpAsState(
+        targetValue = if (selected) 2.dp else 0.dp,
+        label = "card-border",
+    )
+    val alpha by animateFloatAsState(
+        targetValue = if (enabled) 1f else 0.6f,
+        label = "card-alpha",
+    )
+
+    Surface(
+        modifier = Modifier
+            .fillMaxWidth()
+            .shadow(elevation, RoundedCornerShape(20.dp))
+            .border(
+                width = borderWidth,
+                color = MaterialTheme.colorScheme.primary,
+                shape = RoundedCornerShape(20.dp),
+            )
+            .clickable(enabled = enabled, onClick = onClick),
+        shape = RoundedCornerShape(20.dp),
+        color = MaterialTheme.colorScheme.surface,
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(16.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Avatar(
+                seed = user.id,
+                initials = user.name.initials(),
+                modifier = Modifier
+                    .size(48.dp),
+            )
+            Spacer(Modifier.width(16.dp))
+            Column(modifier = Modifier.weight(1f)) {
+                Text(
+                    text = user.name,
+                    fontSize = 16.sp,
+                    fontWeight = FontWeight.SemiBold,
+                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha),
+                )
+                Spacer(Modifier.height(2.dp))
+                Text(
+                    text = "ID: ${user.id}",
+                    fontSize = 13.sp,
+                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f * alpha),
+                )
+            }
+            AnimatedVisibility(
+                visible = selected,
+                enter = fadeIn(),
+                exit = fadeOut(),
+            ) {
+                Box(
+                    modifier = Modifier
+                        .size(28.dp)
+                        .background(
+                            color = MaterialTheme.colorScheme.primary,
+                            shape = CircleShape,
+                        ),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    Icon(
+                        imageVector = Icons.Default.Check,
+                        contentDescription = "Selected",
+                        tint = MaterialTheme.colorScheme.onPrimary,
+                        modifier = Modifier.size(18.dp),
+                    )
+                }
+            }
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt b/stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt
--- a/stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt	(revision f942ff2104d74d1998bc3da6fba8be0da783c6d1)
+++ b/stream-chat-android-compose-sample/src/demo/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt	(date 1780407218834)
@@ -32,6 +32,7 @@
 import io.getstream.chat.android.models.InitializationState
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
+import io.getstream.chat.android.compose.sample.feature.groupedchannels.LoginActivity as GroupedChannelsLoginActivity
 
 /**
  * An Activity without UI responsible for startup routing. It navigates the user to
@@ -48,6 +49,13 @@
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
 
+        // Intercept: route to the grouped-channels demo. Comment out to restore the normal flow.
+        if (USE_GROUPED_CHANNELS_DEMO) {
+            startActivity(Intent(this, GroupedChannelsLoginActivity::class.java))
+            finish()
+            return
+        }
+
         lifecycleScope.launch {
             val userCredentials = ChatApp.credentialsRepository.loadUserCredentials()
             if (userCredentials != null && !BuildConfig.BENCHMARK) {
@@ -100,6 +108,9 @@
     }
 
     companion object {
+        /** Flip to `false` to restore the standard sample flow. */
+        private const val USE_GROUPED_CHANNELS_DEMO = true
+
         private const val KEY_CHANNEL_ID = "channelId"
         private const val KEY_MESSAGE_ID = "messageId"
         private const val KEY_PARENT_MESSAGE_ID = "parentMessageId"
Index: stream-chat-android-compose-sample/src/main/AndroidManifest.xml
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/AndroidManifest.xml b/stream-chat-android-compose-sample/src/main/AndroidManifest.xml
--- a/stream-chat-android-compose-sample/src/main/AndroidManifest.xml	(revision f942ff2104d74d1998bc3da6fba8be0da783c6d1)
+++ b/stream-chat-android-compose-sample/src/main/AndroidManifest.xml	(date 1780407219250)
@@ -98,6 +98,19 @@
             android:windowSoftInputMode="adjustResize"
             />
         <activity android:name=".feature.reminders.MessageRemindersActivity" />
+        <activity
+            android:name=".feature.groupedchannels.LoginActivity"
+            android:exported="false"
+            />
+        <activity
+            android:name=".feature.groupedchannels.MainActivity"
+            android:exported="false"
+            />
+        <activity
+            android:name=".feature.groupedchannels.ChannelActivity"
+            android:exported="false"
+            android:windowSoftInputMode="adjustResize"
+            />
         <activity android:name=".ui.profile.UserProfileActivity" />
         <activity android:name=".ui.channel.attachments.ChannelFilesAttachmentsActivity" />
         <activity android:name=".ui.channel.attachments.ChannelMediaAttachmentsActivity" />
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Color.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Color.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Color.kt
new file mode 100644
--- /dev/null	(date 1780407218838)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Color.kt	(date 1780407218838)
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Type.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Type.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Type.kt
new file mode 100644
--- /dev/null	(date 1780407218849)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Type.kt	(date 1780407218849)
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+    bodyLarge = TextStyle(
+        fontFamily = FontFamily.Default,
+        fontWeight = FontWeight.Normal,
+        fontSize = 16.sp,
+        lineHeight = 24.sp,
+        letterSpacing = 0.5.sp,
+    ),
+)
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Theme.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Theme.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Theme.kt
new file mode 100644
--- /dev/null	(date 1780407218843)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/groupedchannels/ui/theme/Theme.kt	(date 1780407218843)
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.feature.groupedchannels.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+    primary = Purple80,
+    secondary = PurpleGrey80,
+    tertiary = Pink80,
+)
+
+private val LightColorScheme = lightColorScheme(
+    primary = Purple40,
+    secondary = PurpleGrey40,
+    tertiary = Pink40,
+)
+
+@Composable
+fun GroupedChannelsSampleTheme(
+    darkTheme: Boolean = isSystemInDarkTheme(),
+    // Dynamic color is available on Android 12+
+    dynamicColor: Boolean = true,
+    content: @Composable () -> Unit,
+) {
+    val colorScheme = when {
+        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+            val context = LocalContext.current
+            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+        }
+
+        darkTheme -> DarkColorScheme
+        else -> LightColorScheme
+    }
+
+    MaterialTheme(
+        colorScheme = colorScheme,
+        typography = Typography,
+        content = content,
+    )
+}

Scenarios to test

  1. Create a channel using the "+" button => The channel should be visible in both "All" / "New" tabs
  2. Receive a message => The unread count should be increased on both "All" / The other tab to which the channel belongs
  3. Read an unread channel => The unread counts should be decreased on both "All" / The other tab to which the channel belongs
  4. Mark a channel as unread => The unread count should be increased on both "All" / The other tab to which the channel belongs
  5. Move a channel to a different group (tap on the channel training icon, and select the group to which the channel will move) => The channel should be moved, and unread counts updated. (note: The channel will ALWAYS stay in the "All" group)
  6. Leave a channel (long press on channel) => Unread counts should be updated
  7. Everything in the Channel (Message List) View should work as usual

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for querying channels organized into server-defined groups with per-group pagination and cursor-based navigation
    • Introduced grouped unread channel counts tracking per group, exposed via global state for UI display
    • New grouped channel list view model supporting group-aware filtering, search, and automatic channel migration between groups
    • Extended event system to propagate grouped unread channel data across notification and message events

Review Change Stack

# Conflicts:
#	stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt
#	stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt
@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled (or ignored for dependabot PRs).

🎉 Great job! This PR is ready for review.

@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.26 MB 5.31 MB 0.05 MB 🟢
stream-chat-android-offline 5.49 MB 5.53 MB 0.04 MB 🟢
stream-chat-android-ui-components 10.64 MB 10.75 MB 0.10 MB 🟢
stream-chat-android-compose 12.87 MB 12.94 MB 0.07 MB 🟢

@github-actions

Copy link
Copy Markdown
Contributor

DB Entities have been updated. Do we need to upgrade DB Version?
Modified Entities :

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt

@VelikovPetar VelikovPetar added the pr:new-feature New feature label May 14, 2026
VelikovPetar and others added 10 commits May 28, 2026 20:47
Brings PR #6426 (Predefined Filters) into the branch alongside the existing
Grouped Channels work. Both implementations coexist as sibling variants of
`QueryChannelsIdentifier` (Standard | Predefined | Grouped); registries,
repositories, state, and the Compose ChannelListViewModel/Factory absorb both
surfaces additively.

Key resolution decisions:
- `QueryChannelsSpec.cids` stays as a body `var` (pre-v6 baseline) to preserve
  binary compatibility with the data-class-generated members. `setCids`
  mutates the var in place.
- `QueryChannelsMutableState` keeps the simpler `(identifier, scope, ...)`
  constructor; initial filter/sort and the spec shape are derived inside the
  state from the identifier, so `StateRegistry` is just a registry-cache lookup.
- `QueryChannelsLogic.fetchChannelsFromCache` adopts v6's identifier-keyed
  signature returning `CachedQueryChannels`; the Grouped offline path
  (`loadOfflineGroupedChannels`) goes through the same signature and unwraps
  `.channels`.
- `@JvmOverloads` dropped on the Compose Factory's Predefined and Grouped
  constructors because their synthesized `(ChatClient, String)` overloads
  clash — see task #23 for the proper fix.

Verification: builds clean across client/state/offline/compose/ui-components,
apiCheck passes, all unit tests on touched modules pass, detekt clean except
for three intentional TODO-marker comments left for follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Bump ChatDatabase version 99 -> 100 for the QueryChannelsEntity.groupKey
  column added on top of main's PredefinedFilters schema.
- Document the 1..10 limit range on queryGroupedChannels[Internal].
- Lock loadOfflineGroupedChannels' read-and-seed under groupedResultMutex
  to prevent interleaving with a concurrent applyGroupedResult.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jun 4, 2026

Copy link
Copy Markdown

@VelikovPetar VelikovPetar merged commit bfdc97e into v6 Jun 4, 2026
16 checks passed
@VelikovPetar VelikovPetar deleted the feature/grouped-channels-endpoint branch June 4, 2026 09:11
@stream-public-bot stream-public-bot added the released Included in a release label Jun 4, 2026
@stream-public-bot

Copy link
Copy Markdown
Contributor

🚀 Available in v6.40.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:new-feature New feature QAed released Included in a release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants