Fixes the perceptual "stale feed on launch" bug: on cold launch the desktop feed paints with whatever local cache had (up to 7 days old) before relays catch up. The live updateFeedWith() path already prepends fresh events silently, but users had no signal that fresh content arrived unless they were already at the top of the feed (auto-snap via StickToTopOnPrepend). This adds a Twitter/Mastodon-style floating pill chip that slides down from above the search header when fresh events have prepended AND the user is scrolled below position 0. Tapping it smooth-scrolls to top and slides the chip back up off-screen. Scrolling to top manually also dismisses it. Implementation: - NewPostsChip + rememberNewPostsChipState in commons/commonMain so any future feed surface (incl. Android, iOS) can adopt it. Desktop wires it today; Android continues with the existing auto-stick + bottom-nav dot pattern. - Visibility predicate is pure-function and unit-tested (5 cases). - Predicate mirrors the inverse of StickToTopOnPrepend's "at top" check so the two systems are mutually exclusive — auto-snap when at top, chip when not. - Chip placement: floating Alignment.TopCenter inside FeedScreen's outer Box, offset by the animated headerSpacerHeight (60.dp normal, 300.dp when search is expanded) so it tracks the header card. - Hoisted lazyListState + headerSpacerHeight one level so the chip can share scroll state with the LazyColumn. Existing viewport-aware metadata loading is unchanged (same lazyListState reference). - Animation: slideInVertically(tween(280, FastOutSlowInEasing)) + fadeIn for enter; slideOutVertically(tween(220, FastOutLinearInEasing)) + fadeOut for exit. Initial/target offset of -fullHeight-16 guarantees the chip is fully off-screen above its rest position. - Per-column scope by construction: each FeedScreen instance has its own chip state (deck mode shows one chip per column). - Resets cleanly on feed mode switch (Following ↔ Global ↔ Custom) because rememberNewPostsChipState is keyed on FeedContentState, which is recreated when viewModel = remember(feedMode, activeFeedId) recomposes. Plan: docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
21 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| feat: New posts chip on desktop feed | feat | active | 2026-06-02 | 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":
LocalRelayStorehydrates events up to 7 days old (LocalRelayStore.kt:143-153)- Feed subscription filters have no
sinceparameter — relay returns its last 200 events regardless of age (FeedSubscription.kt:41-74) - 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:
NewPostsChip— a stateless visual pill (Surface(shape = RoundedCornerShape(999.dp))) wrapped inAnimatedVisibilitywith vertical slide-in/slide-out + fade.rememberNewPostsChipState(feedContentState, listState)— derives chip visibility by observing the feed's top-item id and theLazyListState. Exposesvisible: State<Boolean>anddismiss().- Integration —
FeedScreen.ktmounts the chip inside the existing outerBox, aligned top-center, withoffset(y = headerSpacerHeight + 8.dp)so it sits just below theFeedTabsHeader(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")
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.Loadedwith 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) andLazyListState(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 ownlazyListState(FeedScreen.kt:433, 654).
Performance implications
snapshotFlow { listState.firstVisibleItemIndex + listState.firstVisibleItemScrollOffset }is cheap — already used elsewhere inFeedScreen.kt:664.- One additional
StateFlow<Boolean>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→FeedViewModelcollector →FeedContentState.updateFeedWith→feedContentStateFlow emits newLoadedFeedState. Chip state holder observes the StateFlow and re-evaluates visibility predicate. ExistingStickToTopOnPrependcontinues to observescrollToTopcounter unchanged. - Error propagation: None — chip only renders when
FeedState.Loaded.FeedState.FeedError/Loading/Empty→ chip hidden. - State lifecycle: Chip state is
remembered insideFeedScreen. WhenfeedModeoractiveFeedIdchanges, the parent composable'sremember(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 existingStickToTopOnPrepend+ bottom-nav dot pattern. - Integration test scenarios:
- Cold launch with stale cache: chip should appear when fresh events arrive after subscription EOSE, only if user has scrolled below position 0.
- User scrolls down → events arrive → chip appears → user scrolls back to top manually → chip disappears.
- User taps chip → list animates to top → chip exits upward → top-most note now matches
lastSeenTopId. - User switches Following → Global mid-chip → chip dismisses immediately (state holder resets with new ContentState).
- Deck mode: two feed columns side-by-side, only the column with new arrivals shows its chip.
Acceptance Criteria
Functional
- When
FeedContentState.feedContentemits a new top-most note id ANDlazyListState.firstVisibleItemIndex > 0(orfirstVisibleItemScrollOffset > 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.ArrowUpwardicon to the left of the text. - Chip is anchored at
Alignment.TopCenterof the outerFeedScreenBox, withModifier.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 andlastSeenTopIdupdates to the current top. - When the user reaches position 0 by any means (manual scroll, tap,
StickToTopOnPrependauto-snap), chip auto-dismisses with slide-out animation andlastSeenTopIdupdates. - Switching
feedMode(Following ↔ Global ↔ Custom) resets chip state — chip is hidden on mount of the new mode. - Existing
StickToTopOnPrependbehavior 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, andFeedErrorstates. - Search-expanded state: chip remains visible if conditions hold but its
offset.yfollows 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.surfaceContainerHighfor background,colorScheme.onSurfacefor text/icon. Respects dark/light theme automatically. - Elevation:
Surface(tonalElevation = 4.dp, shadowElevation = 6.dp)— provides separation from feed content.
Quality gates
./gradlew :commons:compileKotlinJvmpasses../gradlew :desktopApp:compileKotlinpasses../gradlew spotlessApplyapplied before commit.- Unit test for
NewPostsChipStatevisibility 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
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt
class NewPostsChipState internal constructor(
private val lastSeenTopId: MutableState<String?>,
val visible: State<Boolean>,
val onTap: suspend () -> Unit,
) {
fun acknowledgeTop(newTopId: String?) {
lastSeenTopId.value = newTopId
}
}
@Composable
fun rememberNewPostsChipState(
feedContentState: FeedContentState,
listState: LazyListState,
): NewPostsChipState {
val lastSeenTopId = remember(feedContentState) { mutableStateOf<String?>(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
// 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
lazyListStatecreation up one level so it's accessible to both the LazyColumn (insideReadingColumn) and the chip overlay. - Capture the
headerSpacerHeightanimated 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.
// 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:
internal fun shouldShowNewPostsChip(
isAtTop: Boolean,
currentTopId: String?,
lastSeenTopId: String?,
): Boolean = !isAtTop && currentTopId != null && lastSeenTopId != null && currentTopId != lastSeenTopId
Cases to cover:
isAtTop = true→ falsecurrentTopId = null(empty feed) → falselastSeenTopId = null(first paint, no acknowledgement yet) → falsecurrentTopId == lastSeenTopId(no new events) → false- All three valid + non-matching ids → true
Manual repro:
./gradlew :desktopApp:run- Open home feed, wait for it to load.
- Scroll down 5–10 items.
- Wait ~5s for relay subscription to deliver fresh events (or trigger via posting from another client).
- Observe chip slides down from above the search header.
- Tap chip → list smooth-scrolls to top, chip slides up off-screen.
- Switch to Global → chip immediately hidden.
- 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
StickToTopOnPrependauto-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 - 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
sinceparameter 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,scrollToTopcounter,updateFeedWithentry pointamethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt:45-52,141,145— existing scroll-to-top pattern and "at top" predicate to mirrordesktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt:604,610-614,654,683,770— outerBox, animatedheaderSpacerHeight,lazyListStatecreation,LazyColumn,FeedTabsHeaderdesktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt:117-202,234-318— per-column FeedScreen instances confirm per-column chip scopedesktopApp/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 conventionscommons/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
surfaceContainerHighvsprimaryContainercolor 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?