Merge pull request #3088 from vitorpamplona/claude/intelligent-fermat-8y2or

Auto-stick feeds to top on prepend with StickToTopOnPrepend
This commit is contained in:
Vitor Pamplona
2026-05-28 11:55:51 -04:00
committed by GitHub
7 changed files with 166 additions and 0 deletions
@@ -63,6 +63,7 @@ fun SaveableFeedContentState(
}
WatchScrollToTop(feedContentState, listState)
StickToTopOnPrepend(feedContentState, listState)
content(listState)
}
@@ -81,6 +82,7 @@ fun SaveableGridFeedContentState(
}
WatchScrollToTop(feedContentState, gridState)
StickToTopOnPrepend(feedContentState, gridState)
content(gridState)
}
@@ -26,8 +26,16 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@Composable
fun WatchScrollToTop(
@@ -88,3 +96,141 @@ fun WatchScrollToTop(
}
}
}
/**
* Keeps the user pinned to index 0 when new items prepend to a feed, but
* only if they were already at the very top right before the update.
*
* Why this is non-trivial: every feed uses stable `key = item.idHex` in
* its lazy list, which makes Compose preserve the visual anchor across
* data changes. When N items prepend, the user's previously-visible
* top item is still on screen but its index is now N — so
* `firstVisibleItemIndex` shifts from 0 to N without any user gesture.
* A naive `if (firstVisibleItemIndex <= 1) scrollToItem(0)` check inside
* `LaunchedEffect(items.firstOrNull())` therefore fails as soon as more
* than one item arrives in the same batch.
*
* The trick: track "was at top" continuously via [snapshotFlow], but
* only flip it from true → false when [LazyListState.isScrollInProgress]
* is true (i.e. the user is actively scrolling). Compose's keyed-item
* shift after a data update does not set that flag — only real touch
* gestures and `animate*` calls do — so data-driven index shifts can
* never poison the cached value. When [firstItemKey] changes (head of
* the list moved), if the cached value is still true, snap back to 0
* with an instant (non-animated) scroll so the prepend appears as
* in-place growth rather than a visible jump-then-scroll.
*
* Most callers should not invoke this directly: [SaveableFeedContentState],
* [SaveableGridFeedContentState], and the analogous wrappers in
* `ui/screen/FeedView.kt` already apply auto-stick to every feed they
* own. Invoke the explicit overload only when the listState is
* constructed outside one of those wrappers, or when the key that
* should trigger the snap is not the default `items.list[0].idHex`
* (e.g. notifications, chats, or feeds keyed on something other than a
* Note's hex id).
*/
@Composable
fun StickToTopOnPrepend(
listState: LazyListState,
firstItemKey: Any?,
) {
stickToTopOnPrepend(
stateKey = listState,
firstItemKey = firstItemKey,
sampler = {
snapshotFlow {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
},
isScrollInProgress = { listState.isScrollInProgress },
firstVisibleItemIndex = { listState.firstVisibleItemIndex },
scrollToTop = { listState.scrollToItem(0) },
)
}
@Composable
fun StickToTopOnPrepend(
gridState: LazyGridState,
firstItemKey: Any?,
) {
stickToTopOnPrepend(
stateKey = gridState,
firstItemKey = firstItemKey,
sampler = {
snapshotFlow {
gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0
}
},
isScrollInProgress = { gridState.isScrollInProgress },
firstVisibleItemIndex = { gridState.firstVisibleItemIndex },
scrollToTop = { gridState.scrollToItem(0) },
)
}
/**
* Auto-stick wired straight to a [FeedContentState]: derives the head
* key from `feedContent → Loaded.feed → list.firstOrNull()?.idHex` so
* callers don't have to collect the inner feed flow themselves. Used
* by the Saveable* wrappers; suitable for any Note-keyed feed.
*/
@Composable
fun StickToTopOnPrepend(
feedContentState: FeedContentState,
listState: LazyListState,
) {
StickToTopOnPrepend(listState, rememberFirstItemIdHex(feedContentState))
}
@Composable
fun StickToTopOnPrepend(
feedContentState: FeedContentState,
gridState: LazyGridState,
) {
StickToTopOnPrepend(gridState, rememberFirstItemIdHex(feedContentState))
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
private fun rememberFirstItemIdHex(feedContentState: FeedContentState): String? {
val flow =
remember(feedContentState) {
feedContentState.feedContent.flatMapLatest { state ->
when (state) {
is FeedState.Loaded -> state.feed.map { it.list.firstOrNull()?.idHex }
else -> flowOf(null)
}
}
}
val key by flow.collectAsStateWithLifecycle(initialValue = null)
return key
}
@Composable
private fun stickToTopOnPrepend(
stateKey: Any,
firstItemKey: Any?,
sampler: () -> Flow<Boolean>,
isScrollInProgress: () -> Boolean,
firstVisibleItemIndex: () -> Int,
scrollToTop: suspend () -> Unit,
) {
// Plain holder instead of mutableStateOf — we only read this inside
// effects, never in composition, so we don't need snapshot tracking.
val wasAtTop = remember { booleanArrayOf(true) }
LaunchedEffect(stateKey) {
sampler().collect { atTop ->
if (atTop) {
wasAtTop[0] = true
} else if (isScrollInProgress()) {
wasAtTop[0] = false
}
}
}
LaunchedEffect(firstItemKey) {
if (firstItemKey != null && wasAtTop[0] && firstVisibleItemIndex() > 0) {
scrollToTop()
}
}
}
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyGridState
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState
@@ -72,6 +73,7 @@ fun SaveableFeedState(
}
WatchScrollToTop(feedContentState, listState)
StickToTopOnPrepend(feedContentState, listState)
content(listState)
}
@@ -90,6 +92,7 @@ fun SaveableGridFeedState(
}
WatchScrollToTop(viewModel.feedState, gridState)
StickToTopOnPrepend(viewModel.feedState, gridState)
content(gridState)
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserCompose
@@ -90,6 +91,8 @@ private fun FeedLoaded(
val items by state.feed.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
StickToTopOnPrepend(listState, items.firstOrNull()?.pubkeyHex)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = rememberFeedContentPadding(FeedPadding),
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyGridState
@@ -157,6 +158,8 @@ private fun BrowseEmojiSetsGridLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
StickToTopOnPrepend(gridState, items.list.firstOrNull()?.idHex)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
state = gridState,
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.BadgeCompose
@@ -151,6 +152,8 @@ private fun FeedLoaded(
val items by loaded.feed.collectAsStateWithLifecycle()
val openPolls by polls.flow.collectAsStateWithLifecycle()
StickToTopOnPrepend(listState, items.list.firstOrNull()?.id())
// Track which card is highlighted (will auto-clear after animation)
var highlightedCardId by remember { mutableStateOf<String?>(null) }
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -133,6 +134,11 @@ private fun FeedLoadedWithPinnedNotes(
state
}
StickToTopOnPrepend(
listState,
pinnedItems?.list?.firstOrNull()?.idHex ?: feedItems?.list?.firstOrNull()?.idHex,
)
LazyColumn(
contentPadding = FeedPadding,
state = listState,