refactor(feeds): hoist auto-stick into Saveable* wrappers

Pulls StickToTopOnPrepend out of every per-feed callsite and into
SaveableFeedContentState, SaveableGridFeedContentState, SaveableFeedState,
and SaveableGridFeedState — the same wrappers that already own
WatchScrollToTop. A new FeedContentState-keyed overload derives the head
key from feedContent → Loaded.feed → list.firstOrNull()?.idHex, so the
wrappers can wire auto-stick without any per-feed plumbing.

Removes the explicit StickToTopOnPrepend calls from FeedLoaded,
PictureFeedLoaded, ArticlesFeedLoaded, NestsFeedLoaded,
WebBookmarksFeedLoaded, GalleryFeedLoaded, DiscoverFeedLoaded,
DiscoverFeedColumnsLoaded, and ChatroomListFeedView — they all consume
listStates created by one of the four wrappers above.

Kept as explicit calls:
- UserFeedView (custom listState, no wrapper)
- CardFeedView (CardFeedContentState — different type)
- TabNotesNewThreads (custom listState, no wrapper)
- BrowseEmojiSetsScreen (doesn't use SaveableGridFeedContentState)

Also extracts the list/grid bodies to a private stickToTopOnPrepend
core that takes the state object as the LaunchedEffect key plus
lambdas for sampling / scroll, and switches the cached flag from
mutableStateOf to a plain BooleanArray holder (read only from effects,
never composition — no snapshot tracking needed). Adds the missing
"why isScrollInProgress gating is safe" line to the KDoc.
This commit is contained in:
Claude
2026-05-28 02:09:40 +00:00
parent ac3f351458
commit ae05bb9abd
11 changed files with 109 additions and 60 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)
}
@@ -50,8 +50,6 @@ fun FeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
@@ -26,11 +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.mutableStateOf
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(
@@ -107,37 +112,40 @@ fun WatchScrollToTop(
*
* 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). Data-driven index
* shifts happen with `isScrollInProgress == false`, so they 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.
* 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?,
) {
val wasAtTop = remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}.collect { atTop ->
if (atTop) {
wasAtTop.value = true
} else if (listState.isScrollInProgress) {
wasAtTop.value = false
stickToTopOnPrepend(
stateKey = listState,
firstItemKey = firstItemKey,
sampler = {
snapshotFlow {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
}
}
LaunchedEffect(firstItemKey) {
if (firstItemKey != null && wasAtTop.value && listState.firstVisibleItemIndex > 0) {
listState.scrollToItem(0)
}
}
},
isScrollInProgress = { listState.isScrollInProgress },
firstVisibleItemIndex = { listState.firstVisibleItemIndex },
scrollToTop = { listState.scrollToItem(0) },
)
}
@Composable
@@ -145,23 +153,84 @@ fun StickToTopOnPrepend(
gridState: LazyGridState,
firstItemKey: Any?,
) {
val wasAtTop = remember { mutableStateOf(true) }
stickToTopOnPrepend(
stateKey = gridState,
firstItemKey = firstItemKey,
sampler = {
snapshotFlow {
gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0
}
},
isScrollInProgress = { gridState.isScrollInProgress },
firstVisibleItemIndex = { gridState.firstVisibleItemIndex },
scrollToTop = { gridState.scrollToItem(0) },
)
}
LaunchedEffect(gridState) {
snapshotFlow {
gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0
}.collect { atTop ->
/**
* 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.value = true
} else if (gridState.isScrollInProgress) {
wasAtTop.value = false
wasAtTop[0] = true
} else if (isScrollInProgress()) {
wasAtTop[0] = false
}
}
}
LaunchedEffect(firstItemKey) {
if (firstItemKey != null && wasAtTop.value && gridState.firstVisibleItemIndex > 0) {
gridState.scrollToItem(0)
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)
}
@@ -31,7 +31,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
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.screen.loggedIn.AccountViewModel
@@ -49,8 +48,6 @@ fun ArticlesFeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
@@ -41,7 +41,6 @@ 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.SaveableFeedContentState
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.screen.loggedIn.AccountViewModel
@@ -115,8 +114,6 @@ private fun FeedLoaded(
val myPubKey = accountViewModel.userProfile().pubkeyHex
StickToTopOnPrepend(listState, items.list.firstOrNull()?.let { chatroomLazyKey(it, myPubKey) })
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
@@ -65,7 +65,6 @@ import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableGridFeedContentState
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.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -477,8 +476,6 @@ private fun DiscoverFeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
@@ -514,8 +511,6 @@ private fun DiscoverFeedColumnsLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = rememberFeedContentPadding(FeedPadding),
@@ -66,7 +66,6 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
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.navigation.routes.Route
@@ -128,8 +127,6 @@ fun NestsFeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
// The DAL emits items pre-sorted in bucket order (LIVE → SCHEDULED
// → ENDED). Walk the list once to find bucket boundaries so we
// can inject sticky section headers without re-sorting on every
@@ -32,7 +32,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
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.screen.loggedIn.AccountViewModel
@@ -49,8 +48,6 @@ fun PictureFeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
@@ -38,7 +38,6 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
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.StickToTopOnPrepend
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -92,8 +91,6 @@ private fun GalleryFeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
val ratio =
if (accountViewModel.settings.modernGalleryStyle()) {
0.8f
@@ -74,7 +74,6 @@ import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
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.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
@@ -192,8 +191,6 @@ private fun WebBookmarksFeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,