From e1462a296914798e8bad4509528908d4e6bff5e0 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 3 Jun 2026 00:09:46 +0200 Subject: [PATCH] fix(chat): keep sticky date header below pinned/overflow items and in sync with visible messages When pinned messages, upcoming events, or the out-of-office banner were visible at the top of the chat, two problems occurred: 1. The sticky date header was drawn behind the overlay instead of below it. 2. The date shown was wrong: the LazyColumn (reverseLayout=true) counted items hidden behind the overlay as "visible", so lastOrNull() picked an older item that the user couldn't see. Track the overlay container height via ViewTreeObserver and pass it to ChatView as stickyHeaderTopOffset. Use it to (a) shift the header's visual position below the overlay, and (b) filter visibleItemsInfo to only items whose bottom edge clears the overlay before selecting the topmost one for the date text. AI-assistant: Claude Code v2.1.142 (Claude Sonnet 4.6) Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 19 +++++++- .../com/nextcloud/talk/ui/chat/ChatView.kt | 46 +++++++++++++------ app/src/main/res/layout/activity_chat.xml | 1 + 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index ba39423bc7..abe223d0f2 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -62,11 +62,19 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.PermissionChecker import androidx.core.content.PermissionChecker.PERMISSION_GRANTED @@ -287,6 +295,7 @@ class ChatActivity : private val chatEmptyStateType = mutableStateOf(null) private val upcomingEventUiState = mutableStateOf(ChatViewModel.UpcomingEventUIState.None) + private val overflowContainerHeightPx = mutableIntStateOf(0) private val startSelectContactForResult = registerForActivityResult( ActivityResultContracts @@ -510,6 +519,10 @@ class ChatActivity : setUpcomingEventContent() + binding.chatOverflowContainer.viewTreeObserver.addOnGlobalLayoutListener { + overflowContainerHeightPx.intValue = binding.chatOverflowContainer.height + } + lifecycleScope.launch { currentUserProvider.getCurrentUser() .onSuccess { user -> @@ -787,6 +800,9 @@ class ChatActivity : openWhenDownloadState.value = (downloadingFileState.value.intersect(visibleIds).isNotEmpty()) } + val overflowHeightDp = with(LocalDensity.current) { + overflowContainerHeightPx.intValue.toDp() + } ChatView( state = ChatViewState( chatItems = uiState.items, @@ -798,7 +814,8 @@ class ChatActivity : highlightedSearchTerm = uiState.highlightedSearchTerm, hasChatPermission = this::participantPermissions.isInitialized && participantPermissions.hasChatPermission(), - downloadingFileState = downloadingFileState.value + downloadingFileState = downloadingFileState.value, + stickyHeaderTopOffset = overflowHeightDp ), callbacks = ChatViewCallbacks( onLoadMore = { messageId, direction -> loadMoreMessages(messageId, direction) }, diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index 8e765b5626..d8670585bb 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -54,9 +54,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nextcloud.talk.R @@ -95,7 +97,8 @@ data class ChatViewState( val chatMode: ChatViewModel.ChatMode = ChatViewModel.ChatMode.DEFAULT_MODE, val highlightedMessageId: Int? = null, val highlightedSearchTerm: String? = null, - val downloadingFileState: List = listOf() + val downloadingFileState: List = listOf(), + val stickyHeaderTopOffset: Dp = 0.dp ) data class ChatViewCallbacks( @@ -263,23 +266,36 @@ fun ChatView( } // Sticky date header - val stickyDateHeaderText by remember(listState, state.chatItems) { + val density = LocalDensity.current + val overflowPx = with(density) { state.stickyHeaderTopOffset.roundToPx() } + + val stickyDateHeaderText by remember(listState, state.chatItems, overflowPx) { derivedStateOf { - state.chatItems.getOrNull( - listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - )?.let { item -> - when (item) { - is ChatViewModel.ChatItem.MessageItem -> - formatTime(item.uiMessage.timestamp * LONG_1000) + val visibleItems = listState.layoutInfo.visibleItemsInfo + val viewportEnd = listState.layoutInfo.viewportEndOffset + // In reverseLayout=true, offsets increase from bottom (newest) to top (oldest). + // An item's bottom edge is at screen Y = viewportEnd - offset; it is visible when that + // is >= overflowPx, i.e. offset <= viewportEnd - overflowPx. + val targetItem = if (overflowPx > 0) { + visibleItems.filter { it.offset <= viewportEnd - overflowPx }.lastOrNull() + } else { + visibleItems.lastOrNull() + } + targetItem?.let { itemInfo -> + state.chatItems.getOrNull(itemInfo.index)?.let { item -> + when (item) { + is ChatViewModel.ChatItem.MessageItem -> + formatTime(item.uiMessage.timestamp * LONG_1000) - is ChatViewModel.ChatItem.DateHeaderItem -> - formatTime(item.date) + is ChatViewModel.ChatItem.DateHeaderItem -> + formatTime(item.date) - is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> - formatTime(item.date) + is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> + formatTime(item.date) - else -> "" - } + else -> "" + } + } ?: "" } ?: "" } } @@ -397,7 +413,7 @@ fun ChatView( text = stickyDateHeaderText, modifier = Modifier .align(Alignment.TopCenter) - .padding(top = 2.dp) + .padding(top = state.stickyHeaderTopOffset + 2.dp) .alpha(stickyDateHeaderAlpha) ) diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 5b42861611..16a10d70a5 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -57,6 +57,7 @@