From 098a74ca53ffe1d998e0fe1fa68b0a49baa5bda7 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 2 Jun 2026 17:16:58 +0300 Subject: [PATCH 1/7] feat(desktop): add "New posts" chip with slide-from-top animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/commons/ui/feeds/NewPostsChip.kt | 117 +++++ .../commons/ui/feeds/NewPostsChipState.kt | 145 +++++++ .../commons/ui/feeds/NewPostsChipStateTest.kt | 85 ++++ .../amethyst/desktop/ui/FeedScreen.kt | 43 +- ...2-feat-new-posts-chip-desktop-feed-plan.md | 409 ++++++++++++++++++ 5 files changed, 789 insertions(+), 10 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipStateTest.kt create mode 100644 docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md 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? From 38a191341f484add610ba5fb05b5dcac5d8f42d5 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 3 Jun 2026 07:31:34 +0300 Subject: [PATCH 2/7] fix(desktop): bump new-posts chip top margin to 16dp Tighter 8dp gap clipped visually too close to the search header card. --- .../kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 409c60a4f9..bbb60dd525 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 @@ -831,7 +831,7 @@ fun FeedScreen( modifier = Modifier .align(Alignment.TopCenter) - .offset(y = headerSpacerHeight + 8.dp), + .offset(y = headerSpacerHeight + 16.dp), ) } From 44febcc77f196164ba6126aa8a836a658c2ad66e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 3 Jun 2026 07:31:49 +0300 Subject: [PATCH 3/7] feat(desktop): add Amethyst logo to Tor and account-loading splashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both loading splashes (the Tor-connect gate and the account-loading screen between Tor active and LoginScreen) now show the Amethyst icon tinted to the theme primary, anchored below the status text. Layout pattern (status-forward, both splashes): spinner → status text → Amethyst logo (96.dp, primary tint) Brief research summary backing the choice: - Apple HIG argues against splash branding, but its model assumes near-instant launch — not applicable here where the Tor gate can block for seconds. - Material Design 2's branded-launch-screen pattern endorses logo + brand color while a placeholder UI loads. - The status-forward order keeps the dynamic info (what we're waiting on) leading and the brand as the anchor below — the right call when the wait is non-trivial. --- .../vitorpamplona/amethyst/desktop/Main.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index eb6b9f7634..16d487cb20 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -713,6 +713,15 @@ fun App( val torStatus by torManager.status.collectAsState() val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) { + val splashIcon = + remember { + val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() + val bitmap = + org.jetbrains.skia.Image + .makeFromEncoded(bytes) + .toComposeImageBitmap() + BitmapPainter(bitmap) + } androidx.compose.foundation.layout.Box( modifier = androidx.compose.ui.Modifier @@ -735,6 +744,19 @@ fun App( } else { androidx.compose.material3.Text("Connecting to Tor...") } + androidx.compose.foundation.layout.Spacer( + modifier = + androidx.compose.ui.Modifier + .height(24.dp), + ) + androidx.compose.material3.Icon( + painter = splashIcon, + contentDescription = "Amethyst", + modifier = + androidx.compose.ui.Modifier + .size(96.dp), + tint = androidx.compose.material3.MaterialTheme.colorScheme.primary, + ) } } return // Nothing below runs until Tor is Active @@ -962,6 +984,15 @@ fun App( when (accountState) { is AccountState.Loading -> { // Branded loading screen while accounts load from storage + val loadingIcon = + remember { + val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() + val bitmap = + org.jetbrains.skia.Image + .makeFromEncoded(bytes) + .toComposeImageBitmap() + BitmapPainter(bitmap) + } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -978,6 +1009,13 @@ fun App( style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, ) + Spacer(Modifier.height(24.dp)) + androidx.compose.material3.Icon( + painter = loadingIcon, + contentDescription = "Amethyst", + modifier = Modifier.size(96.dp), + tint = MaterialTheme.colorScheme.primary, + ) } } } From 37662eea454a575113df7550e6154674e3ffabb9 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 3 Jun 2026 07:33:39 +0300 Subject: [PATCH 4/7] fix(desktop): port StickToTopOnPrepend to commons and apply on home feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real root cause of the "stale feed on launch" perception bug: when fresh events prepend to the desktop home feed, Compose's stable-key diff (`items(loadedState.list, key = { it.idHex })`) preserves the visual anchor on whatever item was already visible. The user's previously-visible top item — once at index 0 — silently shifts to index N as N new items are inserted above the viewport. From the user's perspective the feed looks frozen on stale items even though the underlying state HAS updated; switching screens unmounts FeedScreen, recreates lazyListState at index 0, and on remount paints from the now-current top. Android already handles this with StickToTopOnPrepend (amethyst/.../WatchScrollToTop.kt:133-152), but the helper lived in the Android module and Desktop had no equivalent. Changes: - New commons/.../ui/feeds/StickToTopOnPrepend.kt with the same observer + snapshotFlow trick, ported to use plain `collectAsState` (replacing the Android-only `collectAsStateWithLifecycle` — the effect's lifecycle is already bound to composition via LaunchedEffect). Provides the same overloads: * StickToTopOnPrepend(LazyListState, firstItemKey) * StickToTopOnPrepend(LazyGridState, firstItemKey) * StickToTopOnPrepend(FeedContentState, LazyListState) * StickToTopOnPrepend(FeedContentState, LazyGridState) - FeedScreen wires StickToTopOnPrepend(viewModel.feedState, homeFeedLazyListState) at the same scope as the hoisted lazy list state and the NewPostsChip. Mutually exclusive with the NewPostsChip: the chip's visibility predicate fires when isAtTop is false, the auto-snap fires when isAtTop is true. Together they cover both cases: * user at top → events arrive → auto-snap shows them * user scrolled down → events arrive → chip announces them The Android version in amethyst/.../WatchScrollToTop.kt is left in place to avoid a wider refactor; it can be reduced to a thin delegate in a follow-up. --- .../commons/ui/feeds/StickToTopOnPrepend.kt | 179 ++++++++++++++++++ .../amethyst/desktop/ui/FeedScreen.kt | 9 + 2 files changed, 188 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt new file mode 100644 index 0000000000..119e2ec795 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt @@ -0,0 +1,179 @@ +/* + * 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.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** + * 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). 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. + * + * Commons port of `amethyst/.../WatchScrollToTop.kt` so Desktop and any + * other multiplatform front-end can use the same auto-stick behavior. + * Uses plain `collectAsState` instead of the Android-only + * `collectAsStateWithLifecycle` — equivalent here because the effect's + * lifecycle is already bound to composition via `LaunchedEffect`. + */ +@Composable +fun StickToTopOnPrepend( + listState: LazyListState, + firstItemKey: Any?, +) { + stickToTopOnPrepend( + stateKey = listState, + firstItemKey = firstItemKey, + initialAtTop = { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + }, + sampler = { + snapshotFlow { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + } + }, + isScrollInProgress = { listState.isScrollInProgress }, + firstVisibleItemIndex = { listState.firstVisibleItemIndex }, + scrollToTop = { listState.scrollToItem(0) }, + ) +} + +@Composable +fun StickToTopOnPrepend( + gridState: LazyGridState, + firstItemKey: Any?, +) { + stickToTopOnPrepend( + stateKey = gridState, + firstItemKey = firstItemKey, + initialAtTop = { + gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 + }, + sampler = { + snapshotFlow { + gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 + } + }, + isScrollInProgress = { gridState.isScrollInProgress }, + firstVisibleItemIndex = { gridState.firstVisibleItemIndex }, + scrollToTop = { gridState.scrollToItem(0) }, + ) +} + +/** + * 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. + */ +@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.collectAsState(initial = null) + return key +} + +@Composable +private fun stickToTopOnPrepend( + stateKey: Any, + firstItemKey: Any?, + initialAtTop: () -> Boolean, + 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. + // Seed from the actual restored scroll position: when the user returns + // to a feed via a saved-state lazy list state, the saved offset is + // already in place, and a hardcoded `true` would mis-snap them to 0. + val wasAtTop = remember(stateKey) { booleanArrayOf(initialAtTop()) } + + LaunchedEffect(stateKey) { + sampler().collect { atTop -> + if (atTop) { + wasAtTop[0] = true + } else if (isScrollInProgress()) { + wasAtTop[0] = false + } + } + } + + LaunchedEffect(firstItemKey) { + if (firstItemKey != null && wasAtTop[0] && firstVisibleItemIndex() > 0) { + scrollToTop() + } + } +} 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 bbb60dd525..375cc1661d 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 @@ -101,6 +101,7 @@ 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.StickToTopOnPrepend import com.vitorpamplona.amethyst.commons.ui.feeds.rememberNewPostsChipState import com.vitorpamplona.amethyst.commons.ui.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.commons.util.toTimeAgo @@ -682,6 +683,14 @@ fun FeedScreen( animationSpec = tween(200), ) + // Auto-snap to position 0 when fresh events prepend AND the user was + // already at the top. Without this, Compose's stable-key diff keeps + // the previously-visible top item anchored, pushing the new items + // silently above the viewport — the root cause of the "stale feed on + // launch" perception. Mutually exclusive with the NewPostsChip below, + // which handles the case where the user is scrolled away from top. + StickToTopOnPrepend(viewModel.feedState, homeFeedLazyListState) + Box(modifier = Modifier.fillMaxSize()) { // Layer 1: Feed content (scrollable, behind scrim) ReadingColumn { From 99af0f75e1f525e6239de84bd53ddce4cabadb9c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 3 Jun 2026 09:31:02 +0300 Subject: [PATCH 5/7] fix(desktop): default home tab to first pinned feed, not last-saved mode If the user has pinned only Global (or only a custom feed), the app should open to that on launch instead of showing Following just because DesktopPreferences.feedMode happened to be saved as Following. The "pinned feeds" list is the user's stated ordering; the first item should drive the initial tab. Resolution order (most specific wins): 1. explicit customFeedSource/customFeedId from the caller 2. explicit initialFeedMode from the caller 3. first pinned feed in feedRepo.pinnedFeeds (NEW) 4. DesktopPreferences.feedMode (last-saved, previous default) For a pinned Filter feed, this also seeds activeFeedId and activeFeedSource so the feed mounts in CUSTOM mode with the right source. --- .../amethyst/desktop/ui/FeedScreen.kt | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) 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 375cc1661d..7759865575 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 @@ -431,13 +431,39 @@ fun FeedScreen( } } var showRelayPicker by remember { mutableStateOf(false) } - var activeFeedId by remember { mutableStateOf(customFeedId) } + + // Default tab on launch = the first PINNED feed (Following/Global/Custom), + // not whatever DesktopPreferences happened to save last. If a caller + // explicitly passes customFeedId/initialFeedMode, that always wins. + val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current + val firstPinned = remember { feedRepo.pinnedFeeds.value.firstOrNull() } + val firstPinnedCustomSource = firstPinned?.source as? com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter + + var activeFeedId by remember { + mutableStateOf( + customFeedId + ?: firstPinned?.takeIf { firstPinnedCustomSource != null }?.id, + ) + } var activeFeedSource by remember { - mutableStateOf(customFeedSource) + mutableStateOf( + customFeedSource ?: firstPinnedCustomSource, + ) } var feedMode by remember { mutableStateOf( - if (customFeedSource != null) FeedMode.CUSTOM else (initialFeedMode ?: DesktopPreferences.feedMode), + when { + customFeedSource != null -> FeedMode.CUSTOM + initialFeedMode != null -> initialFeedMode + firstPinned != null -> + when (firstPinned.source) { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> FeedMode.FOLLOWING + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> FeedMode.GLOBAL + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter -> FeedMode.CUSTOM + else -> DesktopPreferences.feedMode + } + else -> DesktopPreferences.feedMode + }, ) } From e5b210d4e168084e4a8a846c15ebfd50ceef1c75 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 3 Jun 2026 09:36:18 +0300 Subject: [PATCH 6/7] fix(desktop): make first-pinned-feed default actually take effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs that together caused HomeFeed to always open on Following: 1. FeedScreen was reading feedRepo.pinnedFeeds.value as the source of truth for the first pinned feed. That's a stateIn-derived flow with initial value persistentListOf(); the underlying _feeds StateFlow IS loaded synchronously by FeedDefinitionRepository on construction, but the derived pinnedFeeds doesn't reflect it until the first flow emission propagates — which is too late for `remember` to see. Fixed by reading feedRepo.feeds.value directly and filtering / sorting by pinOrder ourselves. 2. DeckColumnContainer was passing initialFeedMode = FeedMode.FOLLOWING when rendering DeckColumnType.HomeFeed, which overrode FeedScreen's first-pinned logic entirely. Removed the hardcode so the deck's home column inherits FeedScreen's default. With both fixed, a user who has only Global pinned now opens to Global on launch instead of Following. --- .../vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 11 ++++++++++- .../amethyst/desktop/ui/deck/DeckColumnContainer.kt | 3 ++- 2 files changed, 12 insertions(+), 2 deletions(-) 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 7759865575..b4269a7745 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 @@ -436,7 +436,16 @@ fun FeedScreen( // not whatever DesktopPreferences happened to save last. If a caller // explicitly passes customFeedId/initialFeedMode, that always wins. val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current - val firstPinned = remember { feedRepo.pinnedFeeds.value.firstOrNull() } + // Read feeds.value (the source StateFlow that's loaded synchronously by + // FeedDefinitionRepository on construction) rather than pinnedFeeds.value — + // the latter is a stateIn-derived flow whose initial value is an empty list + // until the first flow emission propagates, which is too late for `remember`. + val firstPinned = + remember { + feedRepo.feeds.value + .filter { it.pinned } + .minByOrNull { it.pinOrder } + } val firstPinnedCustomSource = firstPinned?.source as? com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter var activeFeedId by remember { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 90f0dbce05..7b673bf0e5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -327,6 +327,8 @@ internal fun RootContent( when (columnType) { DeckColumnType.HomeFeed -> { + // Don't hardcode initialFeedMode — let FeedScreen pick the first + // pinned feed (Following/Global/Custom) as the default tab. FeedScreen( relayManager = relayManager, localCache = localCache, @@ -334,7 +336,6 @@ internal fun RootContent( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, - initialFeedMode = FeedMode.FOLLOWING, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, From 599a16193acb755d8917d6aad467012a969f4280 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 3 Jun 2026 09:46:15 +0300 Subject: [PATCH 7/7] =?UTF-8?q?fix(desktop):=20collapsed=20sidebar=20?= =?UTF-8?q?=E2=80=94=20tighter=20ripple=20+=20hover=20tooltip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related polish fixes on the collapsed sidebar: 1. The hover/active highlight on each nav item used to span the full sidebar width (minus 8dp outer padding), producing ~12dp of empty highlight either side of the 24dp icon. Now the highlight clips to a 40dp square centered on the icon (24dp icon + 8dp padding on each side), so the ripple sits tight against the glyph. 2. When the sidebar is collapsed, the label was already supplied as `contentDescription` for screen readers but had no visual affordance. Added a `TooltipArea` that surfaces the label on hover (Surface + inverseSurface tonal style, matching the existing TorStatusIndicator tooltip pattern), so mouse users can also see what each icon means without expanding the sidebar. Applied to both `SidebarNavItem` and `SidebarFeedItem` since both suffer the same issue. Expanded behaviour is unchanged. --- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 209 ++++++++++++------ 1 file changed, 140 insertions(+), 69 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 623819af24..1d0543c9c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -26,8 +26,12 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -40,11 +44,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -434,7 +440,7 @@ private fun SidebarAccountHeader( /** * A single navigation item row with icon + optional label. */ -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) @Composable private fun SidebarNavItem( icon: MaterialSymbol, @@ -468,48 +474,88 @@ private fun SidebarNavItem( else -> MaterialTheme.colorScheme.onSurfaceVariant } - Row( + val itemRow: @Composable () -> Unit = { + Row( + modifier = + Modifier + .then(if (expanded) Modifier.fillMaxWidth() else Modifier.size(40.dp)) + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .background(backgroundColor) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (expanded) Arrangement.Start else Arrangement.Center, + ) { + Icon( + icon, + contentDescription = if (!expanded) label else null, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive && !muted) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } + + Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(MaterialTheme.shapes.small) - .clickable(onClick = onClick) - .background(backgroundColor) - .onPointerEvent(PointerEventType.Enter) { isHovered = true } - .onPointerEvent(PointerEventType.Exit) { isHovered = false } - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 8.dp, vertical = 2.dp), + contentAlignment = if (expanded) Alignment.CenterStart else Alignment.Center, ) { - Icon( - icon, - contentDescription = if (!expanded) label else null, - tint = iconTint, - modifier = Modifier.size(24.dp), - ) - - AnimatedVisibility( - visible = expanded, - enter = fadeIn(tween(200, delayMillis = 100)), - exit = fadeOut(tween(100)), - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isActive && !muted) FontWeight.SemiBold else FontWeight.Normal, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 12.dp), - ) + if (!expanded) { + TooltipArea( + tooltip = { SidebarTooltip(label) }, + tooltipPlacement = + TooltipPlacement.CursorPoint( + alignment = Alignment.BottomEnd, + offset = DpOffset(8.dp, 0.dp), + ), + ) { + itemRow() + } + } else { + itemRow() } } } +@Composable +private fun SidebarTooltip(text: String) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.inverseSurface, + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.bodySmall, + ) + } +} + /** * A custom feed item in the sidebar. */ -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) @Composable private fun SidebarFeedItem( feed: FeedDefinition, @@ -540,48 +586,73 @@ private fun SidebarFeedItem( MaterialTheme.colorScheme.onSurfaceVariant } - Row( + val itemRow: @Composable () -> Unit = { + Row( + modifier = + Modifier + .then(if (expanded) Modifier.fillMaxWidth() else Modifier.size(40.dp)) + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .background(backgroundColor) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (expanded) Arrangement.Start else Arrangement.Center, + ) { + if (feed.emoji.isNotEmpty() && expanded) { + Text( + text = feed.emoji, + modifier = Modifier.size(24.dp), + style = MaterialTheme.typography.titleMedium, + ) + } else { + Icon( + MaterialSymbols.AutoMirrored.Feed, + contentDescription = if (!expanded) feed.name else null, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = feed.name.ifEmpty { "Feed" }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } + + Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(MaterialTheme.shapes.small) - .clickable(onClick = onClick) - .background(backgroundColor) - .onPointerEvent(PointerEventType.Enter) { isHovered = true } - .onPointerEvent(PointerEventType.Exit) { isHovered = false } - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 8.dp, vertical = 2.dp), + contentAlignment = if (expanded) Alignment.CenterStart else Alignment.Center, ) { - if (feed.emoji.isNotEmpty() && expanded) { - Text( - text = feed.emoji, - modifier = Modifier.size(24.dp), - style = MaterialTheme.typography.titleMedium, - ) + if (!expanded) { + TooltipArea( + tooltip = { SidebarTooltip(feed.name.ifEmpty { "Feed" }) }, + tooltipPlacement = + TooltipPlacement.CursorPoint( + alignment = Alignment.BottomEnd, + offset = DpOffset(8.dp, 0.dp), + ), + ) { + itemRow() + } } else { - Icon( - MaterialSymbols.AutoMirrored.Feed, - contentDescription = if (!expanded) feed.name else null, - tint = iconTint, - modifier = Modifier.size(24.dp), - ) - } - - AnimatedVisibility( - visible = expanded, - enter = fadeIn(tween(200, delayMillis = 100)), - exit = fadeOut(tween(100)), - ) { - Text( - text = feed.name.ifEmpty { "Feed" }, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 12.dp), - ) + itemRow() } } }