diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt new file mode 100644 index 0000000000..43eca3c07f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import kotlinx.coroutines.launch + +/** + * Twitter/Mastodon-style "New posts" floating pill chip. Slides down from above + * its anchor when [NewPostsChipState.visible] flips true; slides back up on + * dismissal. Tapping triggers an animated smooth-scroll to position 0 of the + * associated [androidx.compose.foundation.lazy.LazyListState] and acknowledges + * the new top so the chip exits. + * + * Pair with [rememberNewPostsChipState]. The caller is responsible for + * placement — typically inside a [androidx.compose.foundation.layout.Box] + * overlay aligned to the top of the feed area, offset below any sticky header. + */ +@Composable +fun NewPostsChip( + state: NewPostsChipState, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val visible by state.visible + + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = + slideInVertically( + animationSpec = tween(durationMillis = 280, easing = FastOutSlowInEasing), + initialOffsetY = { fullHeight -> -fullHeight - 16 }, + ) + fadeIn(animationSpec = tween(durationMillis = 220)), + exit = + slideOutVertically( + animationSpec = tween(durationMillis = 220, easing = FastOutLinearInEasing), + targetOffsetY = { fullHeight -> -fullHeight - 16 }, + ) + fadeOut(animationSpec = tween(durationMillis = 180)), + ) { + Surface( + onClick = { scope.launch { state.dismissAndScrollToTop() } }, + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 6.dp, + modifier = + Modifier + .height(36.dp) + .semantics { + contentDescription = "New posts available, tap to scroll to top" + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + symbol = MaterialSymbols.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "New posts", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt new file mode 100644 index 0000000000..7a28125ced --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** + * Pure predicate for chip visibility. Extracted so it can be unit-tested + * without spinning up Compose. Mirrors the inverse of `StickToTopOnPrepend`'s + * "user is at top" check in `WatchScrollToTop.kt` so the two systems are + * mutually exclusive: auto-snap when at top, chip when not. + */ +internal fun shouldShowNewPostsChip( + isAtTop: Boolean, + currentTopId: String?, + lastSeenTopId: String?, +): Boolean = + !isAtTop && + currentTopId != null && + lastSeenTopId != null && + currentTopId != lastSeenTopId + +@Stable +class NewPostsChipState internal constructor( + val visible: State, + private val acknowledgeCurrentTop: () -> Unit, + private val animateScrollToTop: suspend () -> Unit, +) { + /** + * Tap handler: acknowledge the current top BEFORE the scroll begins so the + * visibility predicate flips to false immediately and the chip's exit + * animation runs in parallel with the scroll — visually smoother than + * waiting until the scroll lands and the predicate updates via the + * `isAtTop` observer. + */ + suspend fun dismissAndScrollToTop() { + acknowledgeCurrentTop() + animateScrollToTop() + } +} + +/** + * Derives chip visibility from a [FeedContentState] (live head of the feed) + * and a [LazyListState] (the user's scroll position). The returned state is + * keyed on both inputs so that switching feeds (e.g. Following → Global, which + * recreates the view model and ContentState) resets the chip's "last seen + * top" baseline. + */ +@Composable +fun rememberNewPostsChipState( + feedContentState: FeedContentState, + listState: LazyListState, +): NewPostsChipState { + val lastSeenTopId = remember(feedContentState) { mutableStateOf(null) } + val currentTopId = rememberCurrentTopIdHex(feedContentState) + val currentTopIdState = rememberUpdatedState(currentTopId) + + val isAtTop by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + } + } + + // Bootstrap the baseline on first paint, and re-acknowledge whenever the + // user reaches the top by any means (manual scroll, StickToTopOnPrepend + // auto-snap, or our own chip tap). + LaunchedEffect(currentTopId, isAtTop) { + if (lastSeenTopId.value == null && currentTopId != null) { + lastSeenTopId.value = currentTopId + } else if (isAtTop && currentTopId != null) { + lastSeenTopId.value = currentTopId + } + } + + val visible = + remember(lastSeenTopId, currentTopIdState) { + derivedStateOf { + shouldShowNewPostsChip( + isAtTop = isAtTop, + currentTopId = currentTopIdState.value, + lastSeenTopId = lastSeenTopId.value, + ) + } + } + + return remember(feedContentState, listState) { + NewPostsChipState( + visible = visible, + acknowledgeCurrentTop = { + lastSeenTopId.value = currentTopIdState.value + }, + animateScrollToTop = { + listState.animateScrollToItem(0) + }, + ) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +private fun rememberCurrentTopIdHex(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.collectAsState(initial = null) + return key +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipStateTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipStateTest.kt new file mode 100644 index 0000000000..afe7a0a9f9 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipStateTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NewPostsChipStateTest { + @Test + fun `hides when user is at top`() { + assertFalse( + shouldShowNewPostsChip( + isAtTop = true, + currentTopId = "new", + lastSeenTopId = "old", + ), + ) + } + + @Test + fun `hides when feed has no top item yet`() { + assertFalse( + shouldShowNewPostsChip( + isAtTop = false, + currentTopId = null, + lastSeenTopId = null, + ), + ) + } + + @Test + fun `hides before first acknowledgement is recorded`() { + // First paint: user has not yet seen any top; we initialize the baseline + // synchronously in the LaunchedEffect, but the predicate must be false + // when lastSeenTopId is null so we never show a chip during bootstrap. + assertFalse( + shouldShowNewPostsChip( + isAtTop = false, + currentTopId = "a", + lastSeenTopId = null, + ), + ) + } + + @Test + fun `hides when current top equals last seen top`() { + assertFalse( + shouldShowNewPostsChip( + isAtTop = false, + currentTopId = "a", + lastSeenTopId = "a", + ), + ) + } + + @Test + fun `shows when user is scrolled down and a new top has arrived`() { + assertTrue( + shouldShowNewPostsChip( + isAtTop = false, + currentTopId = "new", + lastSeenTopId = "old", + ), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ff25760a79..409c60a4f9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -43,11 +43,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilterChip @@ -98,6 +100,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.elements.BoostedMark import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip +import com.vitorpamplona.amethyst.commons.ui.feeds.rememberNewPostsChipState import com.vitorpamplona.amethyst.commons.ui.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.DesktopPreferences @@ -670,16 +674,18 @@ fun FeedScreen( ) } + // Hoisted so the floating "New posts" chip can share the same scroll + // state as the LazyColumn and follow the animated header height. + val homeFeedLazyListState = rememberLazyListState() + val headerSpacerHeight by animateDpAsState( + targetValue = if (searchActive) 300.dp else 60.dp, + animationSpec = tween(200), + ) + Box(modifier = Modifier.fillMaxSize()) { // Layer 1: Feed content (scrollable, behind scrim) ReadingColumn { - // Reserve space for the header card that floats above - // Reserve space for the header card that floats above. - // When search is expanded, the card grows — add more margin. - val headerSpacerHeight by animateDpAsState( - targetValue = if (searchActive) 300.dp else 60.dp, - animationSpec = tween(200), - ) + // Reserve space for the header card that floats above the feed. Spacer(Modifier.height(headerSpacerHeight)) // Feed content based on FeedState @@ -720,9 +726,7 @@ fun FeedScreen( is FeedState.Loaded -> { val loadedState by state.feed.collectAsState() - val lazyListState = - androidx.compose.foundation.lazy - .rememberLazyListState() + val lazyListState = homeFeedLazyListState // Viewport-aware scroll observation: fetch metadata for newly visible notes LaunchedEffect(lazyListState, loadedState) { @@ -812,6 +816,25 @@ fun FeedScreen( } } + // Layer 1.5: "New posts" chip — floats below the header card, + // appears when fresh events prepend while the user is scrolled + // away from the top of the feed. Below the scrim in z-order so + // it dims along with the feed when search is expanded. + if (feedState is FeedState.Loaded) { + val chipState = + rememberNewPostsChipState( + feedContentState = viewModel.feedState, + listState = homeFeedLazyListState, + ) + NewPostsChip( + state = chipState, + modifier = + Modifier + .align(Alignment.TopCenter) + .offset(y = headerSpacerHeight + 8.dp), + ) + } + // Reply dialog if (replyToEvent != null && account != null) { ComposeNoteDialog( diff --git a/docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md b/docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md new file mode 100644 index 0000000000..0990a643cd --- /dev/null +++ b/docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md @@ -0,0 +1,409 @@ +--- +title: "feat: New posts chip on desktop feed" +type: feat +status: active +date: 2026-06-02 +origin: docs/brainstorms/2026-06-02-stale-feed-on-launch-new-posts-chip-brainstorm.md +--- + +# feat: New posts chip on desktop feed + +## Overview + +Add a Twitter/Mastodon-style "New posts" floating pill chip to the Amethyst Desktop home feed. The chip slides down from the top — anchored just below the `FeedTabsHeader` (the search bar / header card) — whenever new events arrive while the user is scrolled away from the top of the list. Tapping the chip smooth-scrolls to position 0 and slides the chip back up off-screen. Scrolling to the top manually dismisses it the same way. + +This addresses the user-reported "stale feed on launch" perception bug (see brainstorm: `docs/brainstorms/2026-06-02-stale-feed-on-launch-new-posts-chip-brainstorm.md`). The bug is **perceptual**, not architectural — the live `updateFeedWith()` reactive path already works; new events silently prepend. Users don't notice because the auto-stick (`StickToTopOnPrepend`) only fires when already at position 0. + +## Problem Statement / Motivation + +On cold launch, three factors combine to make the feed feel "stuck on 4-5 day old items": + +1. `LocalRelayStore` hydrates events up to **7 days old** (`LocalRelayStore.kt:143-153`) +2. Feed subscription filters have **no `since` parameter** — relay returns its last 200 events regardless of age (`FeedSubscription.kt:41-74`) +3. **No perceptual signal** when fresh events finally prepend silently (`FeedContentState.kt:63-68` + `WatchScrollToTop.kt`) + +User confirmation: _"it loads eventually I think. One nice UX would be to show a quick chip or tooltip that animates and shows 'New items - Scroll to top' and tapping on it triggers the scroll."_ + +Per brainstorm: subscription `since` tuning and hydration window changes are explicitly **out of scope**. This plan addresses only the perceptual fix. + +## Proposed Solution + +A reusable Compose Multiplatform composable + state holder in `commons/`, integrated into Desktop `FeedScreen`. Three components: + +1. **`NewPostsChip`** — a stateless visual pill (`Surface(shape = RoundedCornerShape(999.dp))`) wrapped in `AnimatedVisibility` with vertical slide-in/slide-out + fade. +2. **`rememberNewPostsChipState(feedContentState, listState)`** — derives chip visibility by observing the feed's top-item id and the `LazyListState`. Exposes `visible: State` and `dismiss()`. +3. **Integration** — `FeedScreen.kt` mounts the chip inside the existing outer `Box`, aligned top-center, with `offset(y = headerSpacerHeight + 8.dp)` so it sits just below the `FeedTabsHeader` (which expands to 300.dp when search is active and collapses to 60.dp otherwise — chip follows via the animated DP). + +### Animation spec (per user request: "nice slide from top and slide out to top") + +```kotlin +AnimatedVisibility( + visible = chipState.visible.value, + enter = slideInVertically( + animationSpec = tween(280, easing = FastOutSlowInEasing), + initialOffsetY = { fullHeight -> -fullHeight - 16 }, // start above the chip's resting position + ) + fadeIn(tween(220)), + exit = slideOutVertically( + animationSpec = tween(220, easing = FastOutLinearInEasing), + targetOffsetY = { fullHeight -> -fullHeight - 16 }, // exit back upward off-screen + ) + fadeOut(tween(180)), +) +``` + +The `-fullHeight - 16` initial/target offset guarantees the chip is fully off-screen above its anchor point at the start of enter / end of exit, so it never half-appears clipped against the header card. + +### Visibility predicate + +Chip is `visible` when **all three** hold: +- New events have arrived since the user last saw the top (`lastSeenTopId != currentTopId`) +- The user is NOT at the top: `firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0` +- The feed is in `FeedState.Loaded` with a non-empty list + +Predicate matches the inverse of `StickToTopOnPrepend`'s "at top" check (`WatchScrollToTop.kt:141,145`), so the two systems are mutually exclusive — auto-snap when at top, chip when not. + +## Technical Considerations + +### Architecture impacts + +- **Reuse, don't reinvent.** The chip subscribes to `FeedContentState.feedContent` (existing) and `LazyListState` (existing). No new reactive infrastructure. +- **No changes to `FeedContentState`.** Top-item tracking lives in the state holder, not on ContentState — keeps ContentState focused on data. +- **Common module placement.** Composable lives in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt`. Android can adopt it later — out of scope today. +- **Each deck column gets its own independent chip** because each column has its own `viewModel = remember(feedMode, activeFeedId)` and its own `lazyListState` (`FeedScreen.kt:433, 654`). + +### Performance implications + +- `snapshotFlow { listState.firstVisibleItemIndex + listState.firstVisibleItemScrollOffset }` is cheap — already used elsewhere in `FeedScreen.kt:664`. +- One additional `StateFlow` observation per visible feed. Negligible. +- No new subscriptions to relays. + +### Security considerations + +None. This is presentational. + +### Accessibility + +- Chip is a `Surface(onClick = ...)` — natural focus + click target. +- `semantics { contentDescription = "New posts available, tap to scroll to top" }` on the Surface. +- Slide animation respects `LocalDensity`; no fixed-pixel hacks. +- Defer keyboard shortcut (e.g. Home key) to a follow-up — out of scope. + +## System-Wide Impact + +- **Interaction graph:** New events from relays → `DesktopRelaySubscriptionsCoordinator.consumeEvent` → 250ms bundler → `cacheEventStream.emitNewNotes` → `FeedViewModel` collector → `FeedContentState.updateFeedWith` → `feedContent` StateFlow emits new `LoadedFeedState`. **Chip state holder observes the StateFlow** and re-evaluates visibility predicate. Existing `StickToTopOnPrepend` continues to observe `scrollToTop` counter unchanged. +- **Error propagation:** None — chip only renders when `FeedState.Loaded`. `FeedState.FeedError` / `Loading` / `Empty` → chip hidden. +- **State lifecycle:** Chip state is `remember`ed inside `FeedScreen`. When `feedMode` or `activeFeedId` changes, the parent composable's `remember(feedMode, activeFeedId)` causes ViewModel recreation, which resets ContentState, which resets chip state. Per-column scope verified — no cross-column leakage. +- **API surface parity:** Composable is in `commons/` so Android could adopt later. Currently only Desktop wires it. Android continues to use existing `StickToTopOnPrepend` + bottom-nav dot pattern. +- **Integration test scenarios:** + 1. Cold launch with stale cache: chip should appear when fresh events arrive after subscription EOSE, only if user has scrolled below position 0. + 2. User scrolls down → events arrive → chip appears → user scrolls back to top manually → chip disappears. + 3. User taps chip → list animates to top → chip exits upward → top-most note now matches `lastSeenTopId`. + 4. User switches Following → Global mid-chip → chip dismisses immediately (state holder resets with new ContentState). + 5. Deck mode: two feed columns side-by-side, only the column with new arrivals shows its chip. + +## Acceptance Criteria + +### Functional + +- [ ] When `FeedContentState.feedContent` emits a new top-most note id AND `lazyListState.firstVisibleItemIndex > 0` (or `firstVisibleItemScrollOffset > 0`), the chip slides down from above the search header and becomes visible. +- [ ] Chip text reads exactly **"New posts"** (no count — per brainstorm resolved decision). +- [ ] Chip contains a `MaterialSymbols.ArrowUpward` icon to the left of the text. +- [ ] Chip is anchored at `Alignment.TopCenter` of the outer `FeedScreen` Box, with `Modifier.offset(y = headerSpacerHeight + 8.dp)` so it tracks the animated header height (60.dp normal, 300.dp when search is expanded). +- [ ] Slide-in animation: `slideInVertically(tween(280, FastOutSlowInEasing), initialOffsetY = { -it - 16 })` + `fadeIn(tween(220))`. +- [ ] Slide-out animation: `slideOutVertically(tween(220, FastOutLinearInEasing), targetOffsetY = { -it - 16 })` + `fadeOut(tween(180))`. +- [ ] Tapping the chip launches `lazyListState.animateScrollToItem(0)` and triggers exit animation. Chip dismisses and `lastSeenTopId` updates to the current top. +- [ ] When the user reaches position 0 by any means (manual scroll, tap, `StickToTopOnPrepend` auto-snap), chip auto-dismisses with slide-out animation and `lastSeenTopId` updates. +- [ ] Switching `feedMode` (Following ↔ Global ↔ Custom) resets chip state — chip is hidden on mount of the new mode. +- [ ] Existing `StickToTopOnPrepend` behavior is unchanged: when user IS at position 0 and new events prepend, list still auto-snaps to top. +- [ ] In desktop deck view, each column shows its own independent chip — no cross-column leakage. +- [ ] Chip is hidden in `Loading`, `Empty`, and `FeedError` states. +- [ ] Search-expanded state: chip remains visible if conditions hold but its `offset.y` follows the animated 300.dp header height so it stays just below the expanded search card. + +### Non-functional + +- [ ] No regression in feed scroll FPS — animations run at ≥ 60 fps in desktop. +- [ ] Accessibility: chip has `contentDescription = "New posts available, tap to scroll to top"`. +- [ ] Theme: chip uses `MaterialTheme.colorScheme.surfaceContainerHigh` for background, `colorScheme.onSurface` for text/icon. Respects dark/light theme automatically. +- [ ] Elevation: `Surface(tonalElevation = 4.dp, shadowElevation = 6.dp)` — provides separation from feed content. + +### Quality gates + +- [x] `./gradlew :commons:compileKotlinJvm` passes. +- [x] `./gradlew :desktopApp:compileKotlin` passes. +- [x] `./gradlew spotlessApply` applied before commit. +- [x] Unit test for `NewPostsChipState` visibility predicate (pure function over list-state inputs) — 5 cases pass. +- [ ] Manual reproduction: cold launch, leave home feed open, scroll down a few items, wait for relay subscription to deliver fresh events → chip appears with slide-down animation. Tap → smooth scroll to top + slide-up. Verify with screen capture. + +## Implementation Plan + +### File-level changes + +``` +commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ +├── NewPostsChip.kt # NEW — stateless visual composable +└── NewPostsChipState.kt # NEW — state holder + rememberNewPostsChipState() + +commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ +└── NewPostsChipStateTest.kt # NEW — unit tests for visibility predicate + +desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ +└── FeedScreen.kt # MODIFY — wire chip into outer Box +``` + +### Step 1 — `NewPostsChipState.kt` + +```kotlin +// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt + +class NewPostsChipState internal constructor( + private val lastSeenTopId: MutableState, + val visible: State, + val onTap: suspend () -> Unit, +) { + fun acknowledgeTop(newTopId: String?) { + lastSeenTopId.value = newTopId + } +} + +@Composable +fun rememberNewPostsChipState( + feedContentState: FeedContentState, + listState: LazyListState, +): NewPostsChipState { + val lastSeenTopId = remember(feedContentState) { mutableStateOf(null) } + val feedState by feedContentState.feedContent.collectAsState() + + val currentTopId by remember(feedState) { + derivedStateOf { + (feedState as? FeedState.Loaded)?.feed?.value?.list?.firstOrNull()?.idHex + } + } + + val isAtTop by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + } + } + + // Initialize lastSeenTopId on first non-null top + LaunchedEffect(currentTopId) { + if (lastSeenTopId.value == null && currentTopId != null) { + lastSeenTopId.value = currentTopId + } + } + + // Acknowledge top when user reaches it + LaunchedEffect(isAtTop, currentTopId) { + if (isAtTop) lastSeenTopId.value = currentTopId + } + + val visible = remember { + derivedStateOf { + !isAtTop && + currentTopId != null && + lastSeenTopId.value != null && + lastSeenTopId.value != currentTopId + } + } + + val scope = rememberCoroutineScope() + return remember(feedContentState, listState) { + NewPostsChipState( + lastSeenTopId = lastSeenTopId, + visible = visible, + onTap = { + listState.animateScrollToItem(0) + lastSeenTopId.value = currentTopId + }, + ) + } +} +``` + +### Step 2 — `NewPostsChip.kt` + +```kotlin +// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt + +@Composable +fun NewPostsChip( + state: NewPostsChipState, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + AnimatedVisibility( + visible = state.visible.value, + modifier = modifier, + enter = slideInVertically( + animationSpec = tween(280, easing = FastOutSlowInEasing), + initialOffsetY = { -it - 16 }, + ) + fadeIn(tween(220)), + exit = slideOutVertically( + animationSpec = tween(220, easing = FastOutLinearInEasing), + targetOffsetY = { -it - 16 }, + ) + fadeOut(tween(180)), + ) { + Surface( + onClick = { scope.launch { state.onTap() } }, + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 6.dp, + modifier = Modifier + .height(36.dp) + .semantics { + contentDescription = "New posts available, tap to scroll to top" + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(symbol = MaterialSymbols.ArrowUpward, size = 16.dp) + Text( + text = "New posts", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} +``` + +### Step 3 — `FeedScreen.kt` integration + +Inside `FeedScreen`'s outer `Box(Modifier.fillMaxSize())` (line 604): + +- Move `lazyListState` creation up one level so it's accessible to both the LazyColumn (inside `ReadingColumn`) and the chip overlay. +- Capture the `headerSpacerHeight` animated DP so the chip can follow it. +- Add the chip as a new layer between Layer 1 (feed content) and Layer 2 (scrim) — or after Layer 3 (header) so it visually sits above the feed but below the header. Z-order matters: chip should NOT cover the header when search is expanded; placing it BELOW the header in the Box's child order, but with offset that puts it under the header, is correct. + +```kotlin +// Inside FeedScreen, before `Box(modifier = Modifier.fillMaxSize())` at line 604 +val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState() +val headerSpacerHeight by animateDpAsState( + targetValue = if (searchActive) 300.dp else 60.dp, + animationSpec = tween(200), + label = "headerSpacer", +) + +Box(modifier = Modifier.fillMaxSize()) { + // Layer 1: Feed content + ReadingColumn { + Spacer(Modifier.height(headerSpacerHeight)) + when (val state = feedState) { + // ... existing branches ... + is FeedState.Loaded -> { + val loadedState by state.feed.collectAsState() + // ... use lazyListState from outer scope ... + LazyColumn(state = lazyListState, ...) { /* unchanged */ } + } + } + } + + // Layer 1.5: New-posts chip (NEW) — anchored just below the header card + val loadedFeedState = (feedState as? FeedState.Loaded)?.feed?.collectAsState()?.value + if (loadedFeedState != null && loadedFeedState.list.isNotEmpty()) { + val chipState = rememberNewPostsChipState( + feedContentState = viewModel.feedState, + listState = lazyListState, + ) + NewPostsChip( + state = chipState, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = headerSpacerHeight + 8.dp) + .padding(top = 4.dp), + ) + } + + // Layer 2: Search scrim (unchanged) + // Layer 3: FeedTabsHeader (unchanged) + // ... +} +``` + +### Step 4 — Test plan + +**Unit test** (`commons/src/jvmTest/kotlin/.../NewPostsChipStateTest.kt`): + +Extract the pure predicate into a testable helper: + +```kotlin +internal fun shouldShowNewPostsChip( + isAtTop: Boolean, + currentTopId: String?, + lastSeenTopId: String?, +): Boolean = !isAtTop && currentTopId != null && lastSeenTopId != null && currentTopId != lastSeenTopId +``` + +Cases to cover: +- `isAtTop = true` → false +- `currentTopId = null` (empty feed) → false +- `lastSeenTopId = null` (first paint, no acknowledgement yet) → false +- `currentTopId == lastSeenTopId` (no new events) → false +- All three valid + non-matching ids → true + +**Manual repro**: + +1. `./gradlew :desktopApp:run` +2. Open home feed, wait for it to load. +3. Scroll down 5–10 items. +4. Wait ~5s for relay subscription to deliver fresh events (or trigger via posting from another client). +5. Observe chip slides down from above the search header. +6. Tap chip → list smooth-scrolls to top, chip slides up off-screen. +7. Switch to Global → chip immediately hidden. +8. Repeat with search expanded — chip appears below the expanded 300.dp header. + +## Success Metrics + +- Subjective: user (you) confirms feed no longer "feels stuck" on launch. +- Functional: chip appears reliably within 250ms (one bundler cycle) of a fresh event arriving while scrolled. +- No regressions: existing `StickToTopOnPrepend` auto-snap still fires when user is at position 0. + +## Dependencies & Risks + +| Item | Risk | Mitigation | +|------|------|------------| +| `lazyListState` hoisting from inside `FeedState.Loaded` branch up to outer scope | Existing viewport-aware metadata loading (`LaunchedEffect` at line 659) and side-padding logic must continue to work | Keep `lazyListState` reference identical; only its declaration point moves. Verify the `LaunchedEffect(lazyListState, loadedState)` block still composes correctly. | +| Chip overlaps a future floating action button or overlay | Layout collision | Use `Modifier.zIndex(1f)` if z-order issues arise; defer to PR review. | +| Recomposition thrash from `firstVisibleItemScrollOffset` changing on every pixel | Performance | Wrap in `derivedStateOf` (already in plan); only `isAtTop: Boolean` flips trigger downstream recomposition. | +| `MaterialSymbols.ArrowUpward` codepoint not in the subset font | Tofu glyph | `ArrowUpward = MaterialSymbol("")` is already declared in `MaterialSymbols.kt:36` — no font regeneration needed. Verified per research. | +| Animation feels too fast / too slow | Subjective | 280ms in, 220ms out are conservative defaults; tune during manual test. | + +## Sources & References + +### Origin + +- **Brainstorm document:** [`docs/brainstorms/2026-06-02-stale-feed-on-launch-new-posts-chip-brainstorm.md`](../brainstorms/2026-06-02-stale-feed-on-launch-new-posts-chip-brainstorm.md) +- Key decisions carried forward: + - Floating overlay placement (not sticky list item) + - No count — text always reads "New posts" + - Reset on feed mode change + - Scope limited to Desktop; commons composable available for future Android adoption + - Subscription `since` parameter and hydration window changes explicitly out of scope + +### Internal references + +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt:44-231` — feed state holder, `scrollToTop` counter, `updateFeedWith` entry point +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt:45-52,141,145` — existing scroll-to-top pattern and "at top" predicate to mirror +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt:604,610-614,654,683,770` — outer `Box`, animated `headerSpacerHeight`, `lazyListState` creation, `LazyColumn`, `FeedTabsHeader` +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt:117-202,234-318` — per-column FeedScreen instances confirm per-column chip scope +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt:48-78` — existing pill pattern (`Surface(shape = RoundedCornerShape(999.dp), color = surfaceContainerHigh, height = 36.dp)`) — reuse the visual conventions +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt:36` — `ArrowUpward = MaterialSymbol("")` (already in subset font) + +### External references + +None — pattern is standard Compose `AnimatedVisibility` + `slideInVertically` / `slideOutVertically`. Material 3 components in use. + +--- + +## Unanswered Questions + +- chip click target hit-area (36.dp tall pill — minimum touch target on touch displays?) +- exact `surfaceContainerHigh` vs `primaryContainer` color choice — subjective, decide during manual test +- whether to add a thin border/outline for contrast in light theme +- should chip auto-dismiss after N seconds of no interaction, or persist until user acts? (current plan: persist) +- accessibility: should chip announce on appearance via live region, or only on focus? +- should Android adopt the chip in a follow-up plan, or stick with the existing dot + auto-stick pattern?