From e2d9ce2b801af1aaf6c730ab189f8b14454120de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 4 May 2026 14:42:33 +0100 Subject: [PATCH 01/14] Surface unreadLabel on MessageListState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an UnreadLabel field on MessageListState so UI layers can react to the sticky unread-boundary signal without depending on the live unreadCount, which collapses to 0 once the SDK auto-marks the latest message as read on chat open. The new field mirrors MessageListController.unreadLabelState via a collector. Declares UnreadLabel as a typealias in the state package (state/messages/list/UnreadLabel.kt) pointing at MessageListController's nested data class, so MessageListState references the type from its own package without reaching into feature/messages/list. Both names resolve to the same JVM class — existing consumers of MessageListController.UnreadLabel keep compiling. --- .../api/stream-chat-android-ui-common.api | 10 ++++--- .../messages/list/MessageListController.kt | 7 +++++ .../state/messages/list/MessageListState.kt | 5 ++++ .../common/state/messages/list/UnreadLabel.kt | 26 +++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/UnreadLabel.kt diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index 6e014ba8b92..9d4a2965367 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -2737,11 +2737,12 @@ public abstract class io/getstream/chat/android/ui/common/state/messages/list/Me public final class io/getstream/chat/android/ui/common/state/messages/list/MessageListState { public static final field $stable I public fun ()V - public fun (Ljava/util/List;ZZZZZLio/getstream/chat/android/models/User;Ljava/lang/String;ILio/getstream/chat/android/ui/common/state/messages/list/NewMessageState;Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState;)V - public synthetic fun (Ljava/util/List;ZZZZZLio/getstream/chat/android/models/User;Ljava/lang/String;ILio/getstream/chat/android/ui/common/state/messages/list/NewMessageState;Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;ZZZZZLio/getstream/chat/android/models/User;Ljava/lang/String;ILio/getstream/chat/android/ui/common/state/messages/list/NewMessageState;Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState;Lio/getstream/chat/android/ui/common/feature/messages/list/MessageListController$UnreadLabel;)V + public synthetic fun (Ljava/util/List;ZZZZZLio/getstream/chat/android/models/User;Ljava/lang/String;ILio/getstream/chat/android/ui/common/state/messages/list/NewMessageState;Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState;Lio/getstream/chat/android/ui/common/feature/messages/list/MessageListController$UnreadLabel;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/List; public final fun component10 ()Lio/getstream/chat/android/ui/common/state/messages/list/NewMessageState; public final fun component11 ()Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState; + public final fun component12 ()Lio/getstream/chat/android/ui/common/feature/messages/list/MessageListController$UnreadLabel; public final fun component2 ()Z public final fun component3 ()Z public final fun component4 ()Z @@ -2750,8 +2751,8 @@ public final class io/getstream/chat/android/ui/common/state/messages/list/Messa public final fun component7 ()Lio/getstream/chat/android/models/User; public final fun component8 ()Ljava/lang/String; public final fun component9 ()I - public final fun copy (Ljava/util/List;ZZZZZLio/getstream/chat/android/models/User;Ljava/lang/String;ILio/getstream/chat/android/ui/common/state/messages/list/NewMessageState;Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Ljava/util/List;ZZZZZLio/getstream/chat/android/models/User;Ljava/lang/String;ILio/getstream/chat/android/ui/common/state/messages/list/NewMessageState;Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState; + public final fun copy (Ljava/util/List;ZZZZZLio/getstream/chat/android/models/User;Ljava/lang/String;ILio/getstream/chat/android/ui/common/state/messages/list/NewMessageState;Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState;Lio/getstream/chat/android/ui/common/feature/messages/list/MessageListController$UnreadLabel;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Ljava/util/List;ZZZZZLio/getstream/chat/android/models/User;Ljava/lang/String;ILio/getstream/chat/android/ui/common/state/messages/list/NewMessageState;Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState;Lio/getstream/chat/android/ui/common/feature/messages/list/MessageListController$UnreadLabel;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState; public fun equals (Ljava/lang/Object;)Z public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; public final fun getEndOfNewMessagesReached ()Z @@ -2761,6 +2762,7 @@ public final class io/getstream/chat/android/ui/common/state/messages/list/Messa public final fun getParentMessageId ()Ljava/lang/String; public final fun getSelectedMessageState ()Lio/getstream/chat/android/ui/common/state/messages/list/SelectedMessageState; public final fun getUnreadCount ()I + public final fun getUnreadLabel ()Lio/getstream/chat/android/ui/common/feature/messages/list/MessageListController$UnreadLabel; public fun hashCode ()I public final fun isLoading ()Z public final fun isLoadingNewerMessages ()Z diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt index a2038a584c6..b8d305954ba 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt @@ -567,6 +567,8 @@ public class MessageListController( }.launchIn(scope) observeUnreadLabelState() + + unreadLabelState.onEach(::updateUnreadLabel).launchIn(scope) } /** @@ -722,6 +724,11 @@ public class MessageListController( setMessageListState(_messageListState.value.copy(unreadCount = unreadCount)) } + private fun updateUnreadLabel(unreadLabel: UnreadLabel?) { + logger.d { "[updateUnreadLabel] #messageList; unreadLabel: $unreadLabel" } + setMessageListState(_messageListState.value.copy(unreadLabel = unreadLabel)) + } + private fun updateIsLoadingOlderMessages(isLoadingOlderMessages: Boolean) { logger.d { "[updateIsLoadingOlderMessages] #messageList; isLoadingOlderMessages: $isLoadingOlderMessages" } setMessageListState(_messageListState.value.copy(isLoadingOlderMessages = isLoadingOlderMessages)) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListState.kt index ffa23dbb9cf..aeaffd369f2 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListState.kt @@ -33,6 +33,9 @@ import io.getstream.chat.android.models.User * @param unreadCount Count of unread messages in channel or thread. * @param newMessageState The [NewMessageState] of the newly received message. * @param selectedMessageState The current [SelectedMessageState]. + * @param unreadLabel Snapshot of the active unread label, or `null` when there is no unread + * boundary to display. Sticky once set: it persists across auto-read events until the user leaves + * the channel, marks messages unread, or explicitly hides it. */ public data class MessageListState( public val messageItems: List = emptyList(), @@ -46,6 +49,7 @@ public data class MessageListState( public val unreadCount: Int = 0, public val newMessageState: NewMessageState? = null, public val selectedMessageState: SelectedMessageState? = null, + public val unreadLabel: UnreadLabel? = null, ) internal fun MessageListState.stringify(): String { @@ -60,6 +64,7 @@ internal fun MessageListState.stringify(): String { "currentUser.id: ${currentUser?.id}, " + "parentMessageId: $parentMessageId, " + "unreadCount: $unreadCount, " + + "unreadLabel: $unreadLabel, " + "selectedMessageState: $selectedMessageState)" } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/UnreadLabel.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/UnreadLabel.kt new file mode 100644 index 00000000000..75dfacb37fb --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/UnreadLabel.kt @@ -0,0 +1,26 @@ +/* + * 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.ui.common.state.messages.list + +import io.getstream.chat.android.ui.common.feature.messages.list.MessageListController + +/** + * Snapshot of the active unread boundary in a channel. Mirrors the value held by + * [MessageListController.unreadLabelState] — declared in the state package so callers don't have + * to reach into the feature package to reference the type. + */ +public typealias UnreadLabel = MessageListController.UnreadLabel From 90c01d4582d217ef20b0603a97ad5b40c2bdcf34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 4 May 2026 12:01:34 +0100 Subject: [PATCH 02/14] Expose disableUnreadLabelButton on Compose MessageListViewModel Lets the UI dismiss the floating unread-label button (e.g. from a close affordance on the scroll-to-first-unread pill) without affecting the inline unread separator. --- .../api/stream-chat-android-compose.api | 1 + .../compose/viewmodel/messages/MessageListViewModel.kt | 9 +++++++++ 2 files changed, 10 insertions(+) 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 5ec32f15442..c530feec8c9 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -6776,6 +6776,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageL public final fun deleteMessage (Lio/getstream/chat/android/models/Message;)V public final fun deleteMessage (Lio/getstream/chat/android/models/Message;Z)V public static synthetic fun deleteMessage$default (Lio/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel;Lio/getstream/chat/android/models/Message;ZILjava/lang/Object;)V + public final fun disableUnreadLabelButton ()V public final fun dismissAllMessageActions ()V public final fun dismissMessageAction (Lio/getstream/chat/android/ui/common/state/messages/MessageAction;)V public final fun displayPollMoreOptions (Lio/getstream/chat/android/ui/common/state/messages/poll/SelectedPoll;)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel.kt index 71560c15f59..a2da0e4df9b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel.kt @@ -550,6 +550,15 @@ public class MessageListViewModel( messageListController.scrollToFirstUnreadMessage() } + /** + * Hides the floating unread-label button without scrolling. The inline unread separator + * remains visible. Use this for explicit dismiss affordances (e.g. a close icon on the + * scroll-to-first-unread pill). + */ + public fun disableUnreadLabelButton() { + messageListController.disableUnreadLabelButton() + } + /** * Hides the unread label in the messages list (if already visible). */ From 413922b15c66aa636da6ea8924c4b14780ce8ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 4 May 2026 12:02:56 +0100 Subject: [PATCH 03/14] Add ScrollToFirstUnreadButton ChatComponentFactory slot Introduces the floating pill primitive used to jump to the first unread message when the unread boundary sits outside the viewport. The pill exposes two distinct interactions: tapping the label area scrolls to the boundary, while tapping the trailing close icon dismisses the pill without scrolling. Adds the ScrollToFirstUnreadButtonParams holder, the ChatComponentFactory slot, the default composable, and the supporting string resources. The slot is unwired in this commit; the wiring lands in the follow-up. --- .../api/stream-chat-android-compose.api | 29 ++++ .../messages/ScrollToFirstUnreadButton.kt | 135 ++++++++++++++++++ .../compose/ui/theme/ChatComponentFactory.kt | 32 +++++ .../ui/theme/ChatComponentFactoryParams.kt | 19 +++ .../src/main/res/values/strings.xml | 6 + 5 files changed, 221 insertions(+) create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt 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 c530feec8c9..1c810a3940e 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1516,6 +1516,12 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Comp public final fun getLambda$-388063089$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } +public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$ScrollToFirstUnreadButtonKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$ScrollToFirstUnreadButtonKt; + public fun ()V + public final fun getLambda$-1749808746$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; +} + public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$SwipeToReplyIconKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$SwipeToReplyIconKt; public fun ()V @@ -3456,6 +3462,7 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC public fun ReactionsMenu (Lio/getstream/chat/android/compose/ui/theme/ReactionsMenuParams;Landroidx/compose/runtime/Composer;I)V public fun ReactionsMenuContent (Lio/getstream/chat/android/compose/ui/theme/ReactionsMenuContentParams;Landroidx/compose/runtime/Composer;I)V public fun ScrollToBottomButton (Lio/getstream/chat/android/compose/ui/theme/ScrollToBottomButtonParams;Landroidx/compose/runtime/Composer;I)V + public fun ScrollToFirstUnreadButton (Lio/getstream/chat/android/compose/ui/theme/ScrollToFirstUnreadButtonParams;Landroidx/compose/runtime/Composer;I)V public fun SearchInputClearButton (Lio/getstream/chat/android/compose/ui/theme/SearchInputClearButtonParams;Landroidx/compose/runtime/Composer;I)V public fun SearchInputLabel (Lio/getstream/chat/android/compose/ui/theme/SearchInputLabelParams;Landroidx/compose/runtime/Composer;I)V public fun SearchInputLeadingIcon (Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/ui/theme/SearchInputLeadingIconParams;Landroidx/compose/runtime/Composer;I)V @@ -3645,6 +3652,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFacto public static fun ReactionsMenu (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ReactionsMenuParams;Landroidx/compose/runtime/Composer;I)V public static fun ReactionsMenuContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ReactionsMenuContentParams;Landroidx/compose/runtime/Composer;I)V public static fun ScrollToBottomButton (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ScrollToBottomButtonParams;Landroidx/compose/runtime/Composer;I)V + public static fun ScrollToFirstUnreadButton (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ScrollToFirstUnreadButtonParams;Landroidx/compose/runtime/Composer;I)V public static fun SearchInputClearButton (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/SearchInputClearButtonParams;Landroidx/compose/runtime/Composer;I)V public static fun SearchInputLabel (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/SearchInputLabelParams;Landroidx/compose/runtime/Composer;I)V public static fun SearchInputLeadingIcon (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/ui/theme/SearchInputLeadingIconParams;Landroidx/compose/runtime/Composer;I)V @@ -5683,6 +5691,27 @@ public final class io/getstream/chat/android/compose/ui/theme/ScrollToBottomButt public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/compose/ui/theme/ScrollToFirstUnreadButtonParams { + public static final field $stable I + public fun (ZILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;)V + public synthetic fun (ZILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()I + public final fun component3 ()Lkotlin/jvm/functions/Function0; + public final fun component4 ()Lkotlin/jvm/functions/Function0; + public final fun component5 ()Landroidx/compose/ui/Modifier; + public final fun copy (ZILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;)Lio/getstream/chat/android/compose/ui/theme/ScrollToFirstUnreadButtonParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/ScrollToFirstUnreadButtonParams;ZILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/ScrollToFirstUnreadButtonParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getModifier ()Landroidx/compose/ui/Modifier; + public final fun getOnClick ()Lkotlin/jvm/functions/Function0; + public final fun getOnDismiss ()Lkotlin/jvm/functions/Function0; + public final fun getUnreadCount ()I + public final fun getVisible ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/compose/ui/theme/SearchInputClearButtonParams { public static final field $stable I public fun (Lkotlin/jvm/functions/Function0;)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt new file mode 100644 index 00000000000..23696bb89b0 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt @@ -0,0 +1,135 @@ +/* + * 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.ui.components.messages + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens + +private val IconSize = 16.dp + +/** + * A floating pill anchored at the top of the message list that lets the user jump to the first + * unread message when it sits outside the viewport. + * + * The pill exposes two interactions: tapping the label area scrolls to the first unread message, + * and tapping the trailing close (X) icon dismisses the pill without scrolling. + * + * @param unreadCount The number of unread messages to display in the label. + * @param onClick The handler triggered when the user taps the label area. + * @param onDismiss The handler triggered when the user taps the close (X) icon. + * @param modifier The modifier used for styling. + */ +@Composable +internal fun ScrollToFirstUnreadButton( + unreadCount: Int, + onClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .shadow(StreamTokens.spacing2xs, shape = CircleShape) + .testTag("Stream_ScrollToFirstUnreadButton"), + shape = CircleShape, + color = ChatTheme.colors.backgroundCoreElevation1, + contentColor = ChatTheme.colors.textPrimary, + border = BorderStroke(1.dp, ChatTheme.colors.borderCoreSubtle), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .semantics { role = Role.Button } + .padding( + start = StreamTokens.spacingSm, + end = StreamTokens.spacingXs, + top = StreamTokens.spacingXs, + bottom = StreamTokens.spacingXs, + ), + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacing2xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(IconSize), + painter = painterResource(R.drawable.stream_design_ic_arrow_up), + contentDescription = stringResource(R.string.stream_compose_scroll_to_first_unread), + ) + Text( + text = pluralStringResource( + id = R.plurals.stream_compose_scroll_to_first_unread_count, + count = unreadCount, + unreadCount, + ), + style = ChatTheme.typography.metadataEmphasis, + ) + } + Icon( + modifier = Modifier + .clickable(onClick = onDismiss) + .semantics { role = Role.Button } + .testTag("Stream_ScrollToFirstUnreadButton_Dismiss") + .padding( + start = StreamTokens.spacingXs, + end = StreamTokens.spacingSm, + top = StreamTokens.spacingXs, + bottom = StreamTokens.spacingXs, + ) + .size(IconSize), + painter = painterResource(R.drawable.stream_design_ic_xmark), + contentDescription = stringResource(R.string.stream_compose_scroll_to_first_unread_dismiss), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun Preview() { + ChatTheme { + ScrollToFirstUnreadButton( + unreadCount = 9, + onClick = { }, + onDismiss = { }, + ) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 32b08940aca..1af2d04049e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -114,6 +114,7 @@ import io.getstream.chat.android.compose.ui.components.messages.MessageText import io.getstream.chat.android.compose.ui.components.messages.MessageThreadFooter import io.getstream.chat.android.compose.ui.components.messages.QuotedMessage import io.getstream.chat.android.compose.ui.components.messages.ScrollToBottomButton +import io.getstream.chat.android.compose.ui.components.messages.ScrollToFirstUnreadButton import io.getstream.chat.android.compose.ui.components.messages.SegmentedMessageReactions import io.getstream.chat.android.compose.ui.components.messages.SwipeToReplyIcon import io.getstream.chat.android.compose.ui.components.reactionpicker.ReactionsPicker @@ -765,6 +766,37 @@ public interface ChatComponentFactory { } } + /** + * The default scroll-to-first-unread pill shown at the top of the message list when there are + * unread messages and the first unread is outside the viewport. + * + * @param params Parameters for this component. + */ + @Composable + public fun ScrollToFirstUnreadButton(params: ScrollToFirstUnreadButtonParams) { + if (LocalInspectionMode.current) { + if (params.visible) { + ScrollToFirstUnreadButton( + modifier = params.modifier, + unreadCount = params.unreadCount, + onClick = params.onClick, + onDismiss = params.onDismiss, + ) + } + } else { + FadingVisibility( + modifier = params.modifier, + visible = params.visible, + ) { + ScrollToFirstUnreadButton( + unreadCount = params.unreadCount, + onClick = params.onClick, + onDismiss = params.onDismiss, + ) + } + } + } + /** * The default message item component, which renders each [MessageListItemState]'s subtype. * This includes date separators, system messages, and regular messages. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index ad566fa54dd..078408125b0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -504,6 +504,25 @@ public data class ScrollToBottomButtonParams( val modifier: Modifier = Modifier, ) +/** + * Parameters for [ChatComponentFactory.ScrollToFirstUnreadButton]. + * + * @param visible Whether the pill is visible. + * @param unreadCount The number of unread messages to display in the pill label. + * @param onClick Action invoked when the pill body is clicked. Implementations should scroll the + * list to the first unread message. + * @param onDismiss Action invoked when the close (X) affordance is clicked. Implementations + * should hide the pill without scrolling. + * @param modifier Modifier for styling. + */ +public data class ScrollToFirstUnreadButtonParams( + val visible: Boolean, + val unreadCount: Int, + val onClick: () -> Unit, + val onDismiss: () -> Unit, + val modifier: Modifier = Modifier, +) + /** * Parameters for [ChatComponentFactory.MessageItem]. * diff --git a/stream-chat-android-compose/src/main/res/values/strings.xml b/stream-chat-android-compose/src/main/res/values/strings.xml index 1246f011ef8..bbefb878858 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -245,6 +245,12 @@ Only visible to you %1$d of %2$d Scroll to bottom + Scroll to first unread message + Dismiss unread indicator + + %d unread + %d unread + +%1$d Remove attachment GIPHY From bde8534e0593b39f826566fbf280126ee7e964ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 4 May 2026 14:43:01 +0100 Subject: [PATCH 04/14] Wire scroll-to-first-unread pill into the message view Renders the floating pill inside DefaultMessagesHelperContent using the sticky unread label exposed on MessageListState. Visibility derives from unreadLabel.buttonVisibility plus whether the inline unread separator is in the visible viewport, so the pill stays correct even after the SDK auto-marks the latest visible message as read on chat open. Tap on the pill body invokes MessageListViewModel.scrollToFirstUnreadMessage (loading the boundary if needed); tap on the close affordance invokes MessageListViewModel.disableUnreadLabelButton. Both actions are exposed as trailing defaulted callbacks on the public MessageList overloads so that state-only consumers can opt in. The visibility derivedState observes only lazyListState.layoutInfo (a State) and re-keys on unreadSeparatorIndex; buttonVisibility is read on each recomposition from the parameter so it never relies on a stale capture. --- .../api/stream-chat-android-compose.api | 17 ++++-- .../compose/ui/messages/list/MessageList.kt | 14 +++++ .../compose/ui/messages/list/Messages.kt | 61 +++++++++++++++++++ .../compose/ui/theme/ChatComponentFactory.kt | 2 + .../ui/theme/ChatComponentFactoryParams.kt | 6 ++ 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 1c810a3940e..bbd17c40f1c 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -2097,7 +2097,7 @@ public final class io/getstream/chat/android/compose/ui/messages/list/Composable public final class io/getstream/chat/android/compose/ui/messages/list/ComposableSingletons$MessagesKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/list/ComposableSingletons$MessagesKt; public fun ()V - public final fun getLambda$1954099387$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$203042131$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } public final class io/getstream/chat/android/compose/ui/messages/list/MessageContainerKt { @@ -2113,8 +2113,8 @@ public final class io/getstream/chat/android/compose/ui/messages/list/MessageIte } public final class io/getstream/chat/android/compose/ui/messages/list/MessageListKt { - public static final fun MessageList (Lio/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/layout/Arrangement$Vertical;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V - public static final fun MessageList (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V + public static final fun MessageList (Lio/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/layout/Arrangement$Vertical;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun MessageList (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;III)V } public final class io/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState { @@ -5163,18 +5163,23 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageListEmptyTh public final class io/getstream/chat/android/compose/ui/theme/MessageListHelperContentParams { public static final field $stable I - public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function1;)V + public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState; public final fun component2 ()Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState; public final fun component3 ()Landroidx/compose/foundation/layout/PaddingValues; public final fun component4 ()Lkotlin/jvm/functions/Function1; - public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function1;)Lio/getstream/chat/android/compose/ui/theme/MessageListHelperContentParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageListHelperContentParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageListHelperContentParams; + public final fun component5 ()Lkotlin/jvm/functions/Function0; + public final fun component6 ()Lkotlin/jvm/functions/Function0; + public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Lio/getstream/chat/android/compose/ui/theme/MessageListHelperContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageListHelperContentParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageListHelperContentParams; public fun equals (Ljava/lang/Object;)Z public final fun getContentPadding ()Landroidx/compose/foundation/layout/PaddingValues; public final fun getMessageListState ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState; public final fun getMessagesLazyListState ()Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState; + public final fun getOnDismissUnreadLabel ()Lkotlin/jvm/functions/Function0; public final fun getOnScrollToBottomClick ()Lkotlin/jvm/functions/Function1; + public final fun getOnScrollToFirstUnreadClick ()Lkotlin/jvm/functions/Function0; public fun hashCode ()I public fun toString ()Ljava/lang/String; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageList.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageList.kt index 0e2f16ae01c..6c9c1245d02 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageList.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageList.kt @@ -75,6 +75,9 @@ import io.getstream.chat.android.ui.common.state.messages.poll.SelectedPoll * @param onMediaGalleryPreviewResult Handler when the user selects an option in the Media Gallery Preview screen. * @param onMessagesPageEndReached Handler for pagination when the end of newest messages have been reached. * @param onScrollToBottomClicked Handler when the user requests to scroll to the bottom of the messages list. + * @param onScrollToFirstUnreadClicked Handler when the user taps the scroll-to-first-unread pill. + * @param onDismissUnreadLabel Handler when the user dismisses the scroll-to-first-unread pill via + * its close affordance. * @param onPauseAudioRecordingAttachments Handler for lifecycle events. */ @Composable @@ -108,6 +111,8 @@ public fun MessageList( }, onMessagesPageEndReached: (String) -> Unit = { viewModel.onBottomEndRegionReached(it) }, onScrollToBottomClicked: (() -> Unit) -> Unit = { viewModel.scrollToBottom(scrollToBottom = it) }, + onScrollToFirstUnreadClicked: () -> Unit = { viewModel.scrollToFirstUnreadMessage() }, + onDismissUnreadLabel: () -> Unit = { viewModel.disableUnreadLabelButton() }, onPauseAudioRecordingAttachments: () -> Unit = { viewModel.pauseAudioRecordingAttachments() }, ) { MessageList( @@ -122,6 +127,8 @@ public fun MessageList( onScrolledToBottom = onScrollToBottom, onMessagesPageEndReached = onMessagesPageEndReached, onScrollToBottom = onScrollToBottomClicked, + onScrollToFirstUnread = onScrollToFirstUnreadClicked, + onDismissUnreadLabel = onDismissUnreadLabel, onPauseAudioRecordingAttachments = onPauseAudioRecordingAttachments, messageItemParams = { messageListItem -> MessageItemParams( @@ -270,6 +277,9 @@ internal fun DefaultMessageListEmptyContent(modifier: Modifier) { * @param onScrolledToBottom Handler when the user scrolls to the bottom. * @param onMessagesPageEndReached Handler for pagination when the end of newest messages have been reached. * @param onScrollToBottom Handler when the user requests to scroll to the bottom of the messages list. + * @param onScrollToFirstUnread Handler when the user taps the scroll-to-first-unread pill. + * @param onDismissUnreadLabel Handler when the user dismisses the scroll-to-first-unread pill via + * its close affordance. * @param onPauseAudioRecordingAttachments Handler for lifecycle events. * @param messageItemParams Factory that builds [MessageItemParams] for each message list item. */ @@ -287,6 +297,8 @@ public fun MessageList( onScrolledToBottom: () -> Unit = {}, onMessagesPageEndReached: (String) -> Unit = {}, onScrollToBottom: (() -> Unit) -> Unit = {}, + onScrollToFirstUnread: () -> Unit = {}, + onDismissUnreadLabel: () -> Unit = {}, onPauseAudioRecordingAttachments: () -> Unit = {}, messageItemParams: (MessageListItemState) -> MessageItemParams = ::MessageItemParams, ) { @@ -313,6 +325,8 @@ public fun MessageList( onScrolledToBottom = onScrolledToBottom, onMessagesEndReached = onMessagesPageEndReached, onScrollToBottom = onScrollToBottom, + onScrollToFirstUnread = onScrollToFirstUnread, + onDismissUnreadLabel = onDismissUnreadLabel, itemContent = { messageListItem -> with(ChatTheme.componentFactory) { MessageItem(params = messageItemParams(messageListItem)) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt index b3269a61fad..06cbfc2506e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt @@ -55,6 +55,7 @@ import io.getstream.chat.android.compose.ui.theme.MessageListHelperContentParams import io.getstream.chat.android.compose.ui.theme.MessageListItemModifierParams import io.getstream.chat.android.compose.ui.theme.MessageListLoadingMoreItemContentParams import io.getstream.chat.android.compose.ui.theme.ScrollToBottomButtonParams +import io.getstream.chat.android.compose.ui.theme.ScrollToFirstUnreadButtonParams import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.compose.util.isAppInForegroundAsState import io.getstream.chat.android.models.Message @@ -67,6 +68,7 @@ import io.getstream.chat.android.ui.common.state.messages.list.MyOwn import io.getstream.chat.android.ui.common.state.messages.list.NewMessageState import io.getstream.chat.android.ui.common.state.messages.list.Other import io.getstream.chat.android.ui.common.state.messages.list.Typing +import io.getstream.chat.android.ui.common.state.messages.list.UnreadSeparatorItemState import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch @@ -93,6 +95,9 @@ import kotlin.math.abs * @param onScrolledToBottom Handler when the user reaches the bottom of the list. * @param onMessagesEndReached Handler for pagination, when the user reaches chronologically the end of messages. * @param onScrollToBottom Handler when the user requests to scroll to the bottom of the messages list. + * @param onScrollToFirstUnread Handler when the user taps the scroll-to-first-unread pill. + * @param onDismissUnreadLabel Handler when the user dismisses the scroll-to-first-unread pill via + * its close affordance. * @param modifier Modifier for styling. * @param contentPadding Padding values to be applied to the message list surrounding the content inside. * @param helperContent Composable that, by default, represents the helper content featuring scrolling behavior based @@ -115,6 +120,8 @@ internal fun Messages( onScrolledToBottom: () -> Unit, onMessagesEndReached: (String) -> Unit, onScrollToBottom: (() -> Unit) -> Unit, + onScrollToFirstUnread: () -> Unit = {}, + onDismissUnreadLabel: () -> Unit = {}, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(vertical = 16.dp), helperContent: @Composable BoxScope.() -> Unit = { @@ -125,6 +132,8 @@ internal fun Messages( messagesLazyListState = messagesLazyListState, contentPadding = contentPadding, onScrollToBottomClick = onScrollToBottom, + onScrollToFirstUnreadClick = onScrollToFirstUnread, + onDismissUnreadLabel = onDismissUnreadLabel, ), ) } @@ -301,7 +310,11 @@ private fun MessageListState.getVerticalArrangement( * * @param messagesState The state of messages, current message list, thread, user and more. * @param messagesLazyListState The scrolling state of the list, used to manipulate and trigger scroll events. + * @param contentPadding Padding values applied around the message list content. * @param scrollToBottom Handler when the user requests to scroll to the bottom of the messages list. + * @param scrollToFirstUnread Handler when the user taps the scroll-to-first-unread pill. + * @param dismissUnreadLabel Handler when the user dismisses the scroll-to-first-unread pill via + * its close affordance. */ @Suppress("LongMethod") @Composable @@ -310,6 +323,8 @@ internal fun BoxScope.DefaultMessagesHelperContent( messagesLazyListState: MessagesLazyListState, contentPadding: PaddingValues, scrollToBottom: (() -> Unit) -> Unit, + scrollToFirstUnread: () -> Unit = {}, + dismissUnreadLabel: () -> Unit = {}, ) { val lazyListState = messagesLazyListState.lazyListState @@ -370,7 +385,35 @@ internal fun BoxScope.DefaultMessagesHelperContent( areNewestMessagesLoaded, ) + val unreadLabel = messagesState.unreadLabel + val unreadSeparatorIndex = remember(messages) { + messages.indexOfFirst { it is UnreadSeparatorItemState } + } + val isUnreadSeparatorVisible by remember(unreadSeparatorIndex) { + derivedStateOf { + unreadSeparatorIndex >= 0 && + lazyListState.layoutInfo.visibleItemsInfo.any { it.index == unreadSeparatorIndex } + } + } + val scrollToFirstUnreadVisible = isScrollToFirstUnreadVisible( + buttonVisibility = unreadLabel?.buttonVisibility == true, + isUnreadSeparatorVisible = isUnreadSeparatorVisible, + ) + with(ChatTheme.componentFactory) { + ScrollToFirstUnreadButton( + params = ScrollToFirstUnreadButtonParams( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(contentPadding) + .padding(top = StreamTokens.spacingMd), + visible = scrollToFirstUnreadVisible, + unreadCount = unreadLabel?.unreadCount ?: 0, + onClick = scrollToFirstUnread, + onDismiss = dismissUnreadLabel, + ), + ) + ScrollToBottomButton( params = ScrollToBottomButtonParams( modifier = Modifier @@ -514,6 +557,24 @@ private fun shouldScrollToBottomButtonBeVisibleAtIndex(firstVisibleItemIndex: In return abs(firstVisibleItemIndex) >= 3 } +/** + * Determines whether the scroll-to-first-unread pill should be visible. + * + * The pill shows when the controller has produced an unread label with [buttonVisibility] enabled + * and the inline unread separator is not present in the visible viewport — either because the + * boundary lies above the loaded window, or because it has not been loaded into the list yet. + * + * @param buttonVisibility Whether the controller currently allows the button to be shown. + * @param isUnreadSeparatorVisible Whether the inline unread separator is currently within the + * visible viewport. + * + * @return Whether the scroll-to-first-unread pill should be shown. + */ +private fun isScrollToFirstUnreadVisible( + buttonVisibility: Boolean, + isUnreadSeparatorVisible: Boolean, +): Boolean = buttonVisibility && !isUnreadSeparatorVisible + /** * The default loading more indicator. */ diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 1af2d04049e..c5de9a9b4e0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -733,6 +733,8 @@ public interface ChatComponentFactory { messagesLazyListState = params.messagesLazyListState, contentPadding = params.contentPadding, scrollToBottom = params.onScrollToBottomClick, + scrollToFirstUnread = params.onScrollToFirstUnreadClick, + dismissUnreadLabel = params.onDismissUnreadLabel, ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index 078408125b0..5f675cc71aa 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -481,12 +481,18 @@ public data class MessageListEmptyContentParams( * @param messagesLazyListState The lazy list state for scrolling. * @param contentPadding The content padding of the message list. * @param onScrollToBottomClick Action invoked when the scroll to bottom button is clicked. + * @param onScrollToFirstUnreadClick Action invoked when the scroll-to-first-unread pill body is + * clicked. Implementations should scroll the list to the unread boundary. + * @param onDismissUnreadLabel Action invoked when the close affordance on the + * scroll-to-first-unread pill is clicked. Implementations should hide the pill without scrolling. */ public data class MessageListHelperContentParams( val messageListState: MessageListState, val messagesLazyListState: MessagesLazyListState, val contentPadding: PaddingValues, val onScrollToBottomClick: (() -> Unit) -> Unit, + val onScrollToFirstUnreadClick: () -> Unit = {}, + val onDismissUnreadLabel: () -> Unit = {}, ) /** From 95fc80d37a3dade8b1fb691413acce15e8eaad49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 4 May 2026 14:43:19 +0100 Subject: [PATCH 05/14] Make unread-label button dismissal sticky against read-state changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit disableUnreadLabelButton() flipped buttonVisibility on the current label, but observeUnreadLabelState recomputes the label whenever lastReadMessageId changes and the calculator restored buttonVisibility=true. After scrollToFirstUnreadMessage, the auto-mark-read on the now-visible boundary triggered exactly that recomputation and the pill returned. Also tryEmit(false) on showUnreadButtonState so the suppression flows through the calculator on subsequent recomputes. The suppression is per-controller, so it resets when the user leaves and re-enters the channel — matching the Figma spec. A regression test in MessageListControllerTests locks the new contract: after disableUnreadLabelButton, pushing a new lastReadMessageId on channelState.read must not flip buttonVisibility back to true. --- .../messages/list/MessageListController.kt | 9 +++- .../list/MessageListControllerTests.kt | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt index b8d305954ba..a1513bec19e 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt @@ -618,9 +618,16 @@ public class MessageListController( } /** - * Disable the unread label button. + * Disables the unread-label button. + * + * Suppresses the button on the current label and on any label produced by future + * recomputations of [observeUnreadLabelState], so the dismissal survives subsequent + * read-state changes (for example, the auto-mark-read that follows + * [scrollToFirstUnreadMessage]). The suppression is reset when the controller is recreated, + * i.e. when the user leaves and re-enters the channel. */ public fun disableUnreadLabelButton() { + showUnreadButtonState.tryEmit(false) unreadLabelState.value = unreadLabelState.value?.copy(buttonVisibility = false) } diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt index b6fd9d2e1ba..bc7c9650c61 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt @@ -1008,6 +1008,48 @@ internal class MessageListControllerTests { controller.unreadLabelState.value.shouldBeNull() } + @Test + fun `When disableUnreadLabelButton is called, buttonVisibility stays false across read updates`() = runTest { + val user = randomUser() + val firstMessage = randomMessage(id = "last_read_message_id", deletedAt = null, deletedForMe = false) + val secondMessage = randomMessage(id = "first_unread_message_id", deletedAt = null, deletedForMe = false) + val messages = listOf(firstMessage, secondMessage) + val channelRead = MutableStateFlow( + randomChannelUserRead( + user = user, + lastReadMessageId = firstMessage.id, + unreadMessages = 1, + ), + ) + val messagesState = MutableStateFlow(messages) + val controller = Fixture() + .givenCurrentUser(user) + .givenChannelState( + messagesState = messagesState, + read = channelRead, + ) + .get() + + // Sanity: the unread label is published with the button visible. + controller.unreadLabelState.value.shouldNotBeNull() + controller.unreadLabelState.value?.buttonVisibility?.`should be true`() + + controller.disableUnreadLabelButton() + + // The simulated auto-read after a scroll: lastReadMessageId moves forward. + channelRead.emit( + randomChannelUserRead( + user = user, + lastReadMessageId = secondMessage.id, + unreadMessages = 0, + ), + ) + + // The recomputed label must keep buttonVisibility false; otherwise the pill returns + // immediately after the user dismisses or scrolls. + controller.unreadLabelState.value?.buttonVisibility?.`should be false`() + } + @Test fun `When reactToMessage is called with skipPush set to true, sendReaction is invoked with skipPush true`() = runTest { From 527c2f8422f52be81d9f2a8740aec30b9f711ec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 4 May 2026 13:46:44 +0100 Subject: [PATCH 06/14] Tidy ScrollToFirstUnreadButton: tokenise stroke, simplify modifiers Cleanup-only follow-up to the pill UI: - Replace the hardcoded 1.dp stroke with StreamTokens.borderStrokeSubtle so the border respects the design-system token. - Pass role=Role.Button directly to clickable instead of layering a separate semantics modifier; the role lands on the same semantics node either way. - Split each four-edge padding into a vertical+horizontal pair for symmetry with the rest of the codebase's padding patterns. No behaviour or API change. --- .../messages/ScrollToFirstUnreadButton.kt | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt index 23696bb89b0..ed96749ce3d 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt @@ -35,8 +35,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R @@ -71,21 +69,16 @@ internal fun ScrollToFirstUnreadButton( shape = CircleShape, color = ChatTheme.colors.backgroundCoreElevation1, contentColor = ChatTheme.colors.textPrimary, - border = BorderStroke(1.dp, ChatTheme.colors.borderCoreSubtle), + border = BorderStroke(StreamTokens.borderStrokeSubtle, ChatTheme.colors.borderCoreSubtle), ) { Row( verticalAlignment = Alignment.CenterVertically, ) { Row( modifier = Modifier - .clickable(onClick = onClick) - .semantics { role = Role.Button } - .padding( - start = StreamTokens.spacingSm, - end = StreamTokens.spacingXs, - top = StreamTokens.spacingXs, - bottom = StreamTokens.spacingXs, - ), + .clickable(role = Role.Button, onClick = onClick) + .padding(vertical = StreamTokens.spacingXs) + .padding(start = StreamTokens.spacingSm, end = StreamTokens.spacingXs), horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacing2xs), verticalAlignment = Alignment.CenterVertically, ) { @@ -105,15 +98,10 @@ internal fun ScrollToFirstUnreadButton( } Icon( modifier = Modifier - .clickable(onClick = onDismiss) - .semantics { role = Role.Button } + .clickable(role = Role.Button, onClick = onDismiss) .testTag("Stream_ScrollToFirstUnreadButton_Dismiss") - .padding( - start = StreamTokens.spacingXs, - end = StreamTokens.spacingSm, - top = StreamTokens.spacingXs, - bottom = StreamTokens.spacingXs, - ) + .padding(vertical = StreamTokens.spacingXs) + .padding(start = StreamTokens.spacingXs, end = StreamTokens.spacingSm) .size(IconSize), painter = painterResource(R.drawable.stream_design_ic_xmark), contentDescription = stringResource(R.string.stream_compose_scroll_to_first_unread_dismiss), From 8ca7da5a805ff301cf3c0e9f5a7286e8d773d7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 4 May 2026 13:55:16 +0100 Subject: [PATCH 07/14] Document unreadLabelState contract and Compose-friendly alternative Expands the KDoc on MessageListController.unreadLabelState to describe its stickiness, the dismissal contract, and to point Compose-style consumers at MessageListState.unreadLabel (the aggregated mirror) so granular subscribers and UI consumers each pick the better entry point. --- .../feature/messages/list/MessageListController.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt index a1513bec19e..5fb74dfa193 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt @@ -223,7 +223,16 @@ public class MessageListController( public val user: StateFlow = clientState.user /** - * Holds information about the unread label state. + * The active unread boundary for this channel, or `null` when none is shown. + * + * Sticky once non-null: the value persists across auto-read events until the user leaves the + * channel (the controller is recreated), marks messages unread, or [hideUnreadSeparator] is + * invoked. The button visibility stays consistent across recompositions of + * [observeUnreadLabelState] — see [disableUnreadLabelButton] for the dismissal contract. + * + * Use this flow when only the unread boundary is relevant. UI layers consuming the aggregated + * channel state should prefer [io.getstream.chat.android.ui.common.state.messages.list.MessageListState.unreadLabel] + * via [messageListState], which mirrors this value. */ public val unreadLabelState: MutableStateFlow = MutableStateFlow(null) private val showUnreadButtonState = MutableSharedFlow(extraBufferCapacity = 1) From b214bf6d1e3b3f88e4bc70220f2da4086ec7f438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 4 May 2026 14:03:29 +0100 Subject: [PATCH 08/14] Add role to internal Modifier.clickable; reuse in pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal Modifier.clickable helper centralises the SDK's clickable surface (explicit Material 3 ripple, optional bounded clipping). The pill was side-stepping it by calling foundation's clickable directly, drifting from the codebase convention. Add a defaulted role parameter to the helper so the pill can ride the shared ripple while still announcing Role.Button. Two pre-existing trailing-lambda call sites (MediaGalleryPage, MediaGalleryPhotosMenu) import both the foundation and internal clickables; the new role parameter makes the simplified ".clickable { … }" form ambiguous between the two. Disambiguate by passing bounded = true (foundation has no bounded), which preserves the existing ripple-bearing behaviour. --- .../ui/attachments/preview/internal/MediaGalleryPage.kt | 2 +- .../attachments/preview/internal/MediaGalleryPhotosMenu.kt | 2 +- .../ui/components/messages/ScrollToFirstUnreadButton.kt | 2 +- .../getstream/chat/android/compose/ui/util/ModifierUtils.kt | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt index 74dadb012c6..7c74707a6cc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt @@ -340,7 +340,7 @@ internal fun MediaGalleryVideoPage( MediaThumbnail( modifier = Modifier .matchParentSize() - .clickable { + .clickable(bounded = true) { showThumbnail = false player.play() }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPhotosMenu.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPhotosMenu.kt index 60fb010f0c0..87b15085741 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPhotosMenu.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPhotosMenu.kt @@ -188,7 +188,7 @@ private fun MediaGalleryPhotosMenuItem( modifier = Modifier .fillMaxWidth() .aspectRatio(1f) - .clickable { onClick() }, + .clickable(bounded = true) { onClick() }, contentAlignment = Alignment.Center, ) { val data = attachment.imagePreviewData diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt index ed96749ce3d..4a12d67352e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/ScrollToFirstUnreadButton.kt @@ -17,7 +17,6 @@ package io.getstream.chat.android.compose.ui.components.messages import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -40,6 +39,7 @@ import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.clickable private val IconSize = 16.dp diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt index 246a8b62f31..a73bd0e718a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -71,12 +72,15 @@ internal fun Modifier.dragPointerInput( * When `false`, [onClick], and this modifier will appear disabled for accessibility service. * @param onClickLabel Semantic / accessibility label for the click action. Announced by screen readers * (e.g. "double tap to