From ac3f3514585e266e4928a4a209e5404d35acc7b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 23:35:56 +0000 Subject: [PATCH 1/2] feat(feeds): stick to top when items prepend and user is at top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds StickToTopOnPrepend, a Compose helper that auto-scrolls back to index 0 whenever new items land at the head of a feed — but only if the user was already at the very top right before the update. Wired into every FeedLoaded variant: Home/Hashtag (FeedLoaded), Notifications (CardFeedView), Pictures, Articles, Discover (list + grid), WebBookmarks, ProfileGallery, BrowseEmojiSets, Chatroom list, UserFeed, NestsFeed, and TabNotesNewThreads. Why this was broken: every feed uses stable key = item.idHex, so when N items prepend Compose preserves the user's visual anchor by shifting firstVisibleItemIndex from 0 to N. The existing WatchScrollToTop only fires on explicit tab-bar taps, and the LaunchedEffect(items.firstOrNull()) { if (firstVisibleItemIndex <= 1) } pattern (used in ChatFeedView and PublicChatsFeedLoaded) breaks the moment more than one item arrives in the same batch. How the helper avoids the race: it tracks "was at top" continuously via snapshotFlow but only flips it true → false when isScrollInProgress is true. Data-driven index shifts happen with isScrollInProgress == false, so they never poison the cached value. When firstItemKey changes and the cached value is still true, we snap back to 0 with an instant (non-animated) scroll so the prepend appears as in-place growth instead of a visible jump-then-scroll. ChatFeedView is left alone — it already works because reverseLayout masks the prepend shift. --- .../amethyst/ui/feeds/FeedLoaded.kt | 2 + .../amethyst/ui/feeds/WatchScrollToTop.kt | 77 +++++++++++++++++++ .../amethyst/ui/screen/UserFeedView.kt | 3 + .../loggedIn/articles/ArticlesFeedLoaded.kt | 3 + .../chats/rooms/feed/ChatroomListFeedView.kt | 3 + .../loggedIn/discover/DiscoverScreen.kt | 5 ++ .../browse/BrowseEmojiSetsScreen.kt | 3 + .../screen/loggedIn/nests/NestsFeedLoaded.kt | 3 + .../loggedIn/notifications/CardFeedView.kt | 3 + .../loggedIn/pictures/PictureFeedLoaded.kt | 3 + .../profile/gallery/ProfileGalleryFeed.kt | 3 + .../profile/newthreads/TabNotesNewThreads.kt | 6 ++ .../webBookmarks/WebBookmarksScreen.kt | 3 + 13 files changed, 117 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index 1fe04f30f3..940d346668 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -50,6 +50,8 @@ fun FeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index f7cd505d4f..f2923a029f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -26,6 +26,9 @@ 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.ui.screen.loggedIn.notifications.CardFeedContentState @@ -88,3 +91,77 @@ 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). 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. + */ +@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 + } + } + } + + LaunchedEffect(firstItemKey) { + if (firstItemKey != null && wasAtTop.value && listState.firstVisibleItemIndex > 0) { + listState.scrollToItem(0) + } + } +} + +@Composable +fun StickToTopOnPrepend( + gridState: LazyGridState, + firstItemKey: Any?, +) { + val wasAtTop = remember { mutableStateOf(true) } + + LaunchedEffect(gridState) { + snapshotFlow { + gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 + }.collect { atTop -> + if (atTop) { + wasAtTop.value = true + } else if (gridState.isScrollInProgress) { + wasAtTop.value = false + } + } + } + + LaunchedEffect(firstItemKey) { + if (firstItemKey != null && wasAtTop.value && gridState.firstVisibleItemIndex > 0) { + gridState.scrollToItem(0) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 9b7a5895b4..1d0edc75ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt index 534bc03854..9071d52592 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt @@ -31,6 +31,7 @@ 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 @@ -48,6 +49,8 @@ fun ArticlesFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 300f36eda9..1fe76ffca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -41,6 +41,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.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 @@ -114,6 +115,8 @@ private fun FeedLoaded( val myPubKey = accountViewModel.userProfile().pubkeyHex + StickToTopOnPrepend(listState, items.list.firstOrNull()?.let { chatroomLazyKey(it, myPubKey) }) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index ac83a79bd9..8ed88afb28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -65,6 +65,7 @@ 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 @@ -476,6 +477,8 @@ private fun DiscoverFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, @@ -511,6 +514,8 @@ private fun DiscoverFeedColumnsLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex) + LazyVerticalGrid( columns = GridCells.Fixed(2), contentPadding = rememberFeedContentPadding(FeedPadding), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt index 22f686f50b..28e434854b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt index 1dee7888e8..699cee5a66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt @@ -66,6 +66,7 @@ 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 @@ -127,6 +128,8 @@ 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 248ade8d97..4c4fd18d4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -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(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt index c9d972238e..e2da72848e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt @@ -32,6 +32,7 @@ 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 @@ -48,6 +49,8 @@ fun PictureFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt index cc80416fb1..e7a122c32d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt @@ -38,6 +38,7 @@ 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 @@ -91,6 +92,8 @@ private fun GalleryFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex) + val ratio = if (accountViewModel.settings.modernGalleryStyle()) { 0.8f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt index 9b256d96ff..5353327843 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt index 6f73a9e069..ddba22f164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt @@ -74,6 +74,7 @@ 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 @@ -191,6 +192,8 @@ private fun WebBookmarksFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, From ae05bb9abd0892f05ded8a0e2e354c694f3fbdc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 02:09:40 +0000 Subject: [PATCH 2/2] refactor(feeds): hoist auto-stick into Saveable* wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../amethyst/ui/feeds/FeedContentStateView.kt | 2 + .../amethyst/ui/feeds/FeedLoaded.kt | 2 - .../amethyst/ui/feeds/WatchScrollToTop.kt | 139 +++++++++++++----- .../amethyst/ui/screen/FeedView.kt | 3 + .../loggedIn/articles/ArticlesFeedLoaded.kt | 3 - .../chats/rooms/feed/ChatroomListFeedView.kt | 3 - .../loggedIn/discover/DiscoverScreen.kt | 5 - .../screen/loggedIn/nests/NestsFeedLoaded.kt | 3 - .../loggedIn/pictures/PictureFeedLoaded.kt | 3 - .../profile/gallery/ProfileGalleryFeed.kt | 3 - .../webBookmarks/WebBookmarksScreen.kt | 3 - 11 files changed, 109 insertions(+), 60 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt index fb49009cbc..abf9751322 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index 940d346668..1fe04f30f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -50,8 +50,6 @@ fun FeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() - StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex) - LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index f2923a029f..8f27178ddf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -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, + 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() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index ac14f13239..7e878a2bce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt index 9071d52592..534bc03854 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 1fe76ffca0..300f36eda9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index 8ed88afb28..ac83a79bd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt index 699cee5a66..1dee7888e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt index e2da72848e..c9d972238e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt index e7a122c32d..cc80416fb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt index ddba22f164..6f73a9e069 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt @@ -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,