Files
amethyst/docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
T
nrobi144 098a74ca53 feat(desktop): add "New posts" chip with slide-from-top animation
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
2026-06-02 17:16:58 +03:00

21 KiB
Raw Blame History

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":

  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<Boolean> and dismiss().
  3. IntegrationFeedScreen.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")

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<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.emitNewNotesFeedViewModel collector → FeedContentState.updateFeedWithfeedContent 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 remembered 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

  • ./gradlew :commons:compileKotlinJvm passes.
  • ./gradlew :desktopApp:compileKotlin passes.
  • ./gradlew spotlessApply applied before commit.
  • 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

// 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 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.
// 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 → 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 510 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
  • 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:36ArrowUpward = 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?