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,