diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index e5c9a8c..f4fd4b5 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -167,10 +167,14 @@ fun ChatScreen(controller: IrisController) { val threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId } val listState = rememberLazyListState() - // "At the bottom" while the last row's bottom edge is within a small - // tolerance of its rest position (16dp above the viewport bottom, from the - // list's content padding). Scrolling up by more than that reveals the - // back-to-bottom button and stops new messages from auto-scrolling the view. + // "At the bottom" when the list can't scroll further forward, or the last + // row's bottom edge is within a small tolerance of the viewport bottom + // (the tolerance avoids flicker when the newest message is shorter than + // the viewport). The canScrollForward check is what makes this work when + // the newest message is TALLER than the viewport: the gap is then + // negative even at the true bottom. Scrolling up by more than the + // tolerance reveals the back-to-bottom button and stops new messages from + // auto-scrolling the view. val bottomThresholdPx = 8.dp.value * LocalDensity.current.density var isAtBottom by remember { mutableStateOf(true) } var isAutoScrolling by remember { mutableStateOf(false) } @@ -182,10 +186,28 @@ fun ChatScreen(controller: IrisController) { true } else { val gap = (info.viewportEndOffset - (last.offset + last.size)).toFloat() - last.index == info.totalItemsCount - 1 && gap >= bottomThresholdPx + !listState.canScrollForward || + (last.index == info.totalItemsCount - 1 && gap >= bottomThresholdPx) } }.collect { isAtBottom = it } } + + // Scroll so the newest row's BOTTOM edge is visible: first bring the last + // row into view (top-aligned), then, if it is taller than the viewport, + // align its bottom with the viewport bottom. A plain scrollToItem(last) + // would leave a long final message cut off at the bottom. + suspend fun scrollToBottom(animate: Boolean) { + val total = listState.layoutInfo.totalItemsCount + if (total == 0) return + val last = total - 1 + if (animate) listState.animateScrollToItem(last) else listState.scrollToItem(last) + val info = listState.layoutInfo + val item = info.visibleItemsInfo.firstOrNull { it.index == last } ?: return + val offset = (item.offset + item.size - info.viewportSize.height).coerceAtLeast(0) + if (offset > 0) { + if (animate) listState.animateScrollToItem(last, offset) else listState.scrollToItem(last, offset) + } + } var input by remember { mutableStateOf("") } var showSearch by remember { mutableStateOf(false) } var showNewChannel by remember { mutableStateOf(false) } @@ -235,11 +257,8 @@ fun ChatScreen(controller: IrisController) { focusManager.clearFocus(force = true) } - // (Re)entering a lane always lands on the latest message. - LaunchedEffect(currentLane) { - val last = items.size - 1 - if (last >= 0) listState.scrollToItem(last) - } + // (Re)entering a lane always lands on the latest message (bottom-aligned). + LaunchedEffect(currentLane) { scrollToBottom(animate = false) } // Follow the bottom only while the user is already there; if they've // scrolled up to read history, new messages must not yank the view back @@ -250,7 +269,7 @@ fun ChatScreen(controller: IrisController) { if (last >= 0) { isAutoScrolling = true try { - listState.animateScrollToItem(last) + scrollToBottom(animate = true) } finally { isAutoScrolling = false } @@ -551,10 +570,7 @@ fun ChatScreen(controller: IrisController) { .background(IrisColors.surface) .border(1.dp, IrisColors.divider, CircleShape) .clickable { - val last = listState.layoutInfo.totalItemsCount - 1 - if (last >= 0) { - drawerScope.launch { listState.animateScrollToItem(last) } - } + drawerScope.launch { scrollToBottom(animate = true) } }, contentAlignment = Alignment.Center, ) {