From 9a58e4db2bc2b1b6db30c24135df0eb34be4d6a3 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:36:34 +0000 Subject: [PATCH] android, desktop: keep sender avatar visible while their messages scroll When the top of a sender's consecutive messages scrolls under the top bar, the avatar stays below the bar and moves with the messages until the end of the last one pushes it up. Groups only, channels are unchanged. --- .../simplex/common/views/chat/ChatView.kt | 99 ++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 091924e3c7..b411e621c8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -16,8 +16,15 @@ import androidx.compose.ui.draw.* import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -1908,6 +1915,28 @@ fun BoxScope.ChatItemsList( } ) + val listCoordinates = remember { mutableStateOf(null) } + + fun Density.pinnedAvatarTop(index: Int, avatarHeight: Int): Float { + val belowTopBar = (topPaddingToContentPx.value + 4.dp.roundToPx()).toFloat() + val runBottom = senderRunBottom(listState.value.layoutInfo, mergedItems.value.items, index) ?: return belowTopBar + return min(belowTopBar, runBottom - avatarHeight) + } + + // reading coordinates during placement re-runs it on every scroll frame + fun Modifier.stickyInSenderRun(itemId: Long): Modifier = layout { measurable, constraints -> + val avatar = measurable.measure(constraints) + layout(avatar.width, avatar.height) { + val list = listCoordinates.value + val self = coordinates + val index = mergedItems.value.indexInParentItems[itemId]?.takeIf { i -> listState.value.layoutInfo.visibleItemsInfo.any { it.index == i } } + val shift = if (list != null && self != null && index != null) { + max(0f, pinnedAvatarTop(index, avatar.height) - list.localPositionOf(self).y) + } else 0f + avatar.place(0, shift.roundToInt()) + } + } + @Composable fun ChatViewListItem( itemAtZeroIndexInWholeList: Boolean, @@ -2073,7 +2102,7 @@ fun BoxScope.ChatItemsList( } Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { val member = cItem.chatDir.groupMember - Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { + Box((if (chatInfo.isChannel) Modifier else Modifier.stickyInSenderRun(cItem.id)).clickable { showMemberInfo(chatInfo.groupInfo, member) }) { MemberImage(member) } Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { @@ -2373,7 +2402,7 @@ fun BoxScope.ChatItemsList( val modifier = if (appPlatform.isDesktop && manager != null) SelectionHandler(manager, listState, mergedItems, revealedItems, linkMode) else Modifier LazyColumnWithScrollBar( - modifier.align(Alignment.BottomCenter), + modifier.align(Alignment.BottomCenter).onPlaced { listCoordinates.value = it }, state = listState.value, contentPadding = PaddingValues( top = topPaddingToContent, @@ -2467,6 +2496,40 @@ fun BoxScope.ChatItemsList( loadMessages ) FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) + // a lambda has its own restart scope, so the reads below don't recompose ChatItemsList + val PinnedSenderAvatar: @Composable (GroupInfo) -> Unit = pinned@{ groupInfo -> + fun topItem(): Pair? { + val index = listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return null + return mergedItems.value.items.getOrNull(index)?.let { index to it } + } + val memberAtTop = remember { derivedStateOf(sameMemberPolicy) { topItem()?.let { (it.second.newest().item.chatDir as? CIDirection.GroupRcv)?.groupMember } } } + val selectionOffset by animateDpAsState(if (selectedChatItems.value != null) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) + val member = memberAtTop.value ?: return@pinned + Box( + Modifier + .align(Alignment.TopStart) + .layout { measurable, constraints -> + val avatar = measurable.measure(constraints) + layout(avatar.width, avatar.height) { + val (index, top) = topItem() ?: return@layout + if (continuesSenderRun(top) && (top.newest().item.chatDir as CIDirection.GroupRcv).groupMember.memberId == member.memberId) { + val y = pinnedAvatarTop(index, avatar.height).roundToInt() + if (y + avatar.height > topPaddingToContentPx.value) { + avatar.placeRelativeWithLayer(8.dp.roundToPx(), y) { translationX = selectionOffset.toPx() } + } + } + } + } + .clickable { showMemberInfo(groupInfo, member) } + .then(ShareTouchesWithList) + ) { + MemberImage(member) + } + } + + if (chatInfo is ChatInfo.Group && !chatInfo.isChannel) { + PinnedSenderAvatar(chatInfo.groupInfo) + } LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } @@ -3788,6 +3851,38 @@ private fun shouldShowAvatar(current: ChatItem, older: ChatItem?): Boolean { } } +// messages of one sender can carry different snapshots of the member +private val sameMemberPolicy = object : SnapshotMutationPolicy { + override fun equivalent(a: GroupMember?, b: GroupMember?): Boolean = a?.memberId == b?.memberId +} + +// the list under the stand-in avatar still gets the drags and wheel events that scroll it +private data object ShareTouchesWithList : ModifierNodeElement() { + override fun create(): Modifier.Node = object : Modifier.Node(), PointerInputModifierNode { + override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) {} + override fun onCancelPointerInput() {} + override fun sharePointerInputWithSiblings(): Boolean = true + } + override fun update(node: Modifier.Node) {} +} + +private fun continuesSenderRun(merged: MergedItem): Boolean { + val item = merged.newest().item + return item.chatDir is CIDirection.GroupRcv && !shouldShowAvatar(item, merged.oldest().nextItem) +} + +// in list coordinates, null when the run continues below the visible items +private fun senderRunBottom(layoutInfo: LazyListLayoutInfo, items: List, index: Int): Float? { + val firstVisibleIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: return null + var last = index + while (items.getOrNull(last - 1)?.let(::continuesSenderRun) == true) { + last-- + if (last < firstVisibleIndex) return null + } + val lastItem = layoutInfo.visibleItemsInfo.firstOrNull { it.index == last } ?: return null + return (layoutInfo.viewportEndOffset - lastItem.offset).toFloat() +} + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES,