mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge pull request #3126 from nrobi144/feat/desktop-new-posts-chip
feat(desktop): home-feed scroll polish + sidebar tooltips
This commit is contained in:
+117
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
@@ -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<Boolean>,
|
||||
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<String?>(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
|
||||
}
|
||||
+179
@@ -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<Boolean>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 @@ 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.StickToTopOnPrepend
|
||||
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
|
||||
@@ -426,13 +431,48 @@ 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
|
||||
// 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 {
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -670,16 +710,26 @@ 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),
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// 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 +770,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 +860,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 + 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Reply dialog
|
||||
if (replyToEvent != null && account != null) {
|
||||
ComposeNoteDialog(
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+140
-69
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Boolean>` 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<Boolean>` observation per visible feed. Negligible.
|
||||
- No new subscriptions to relays.
|
||||
|
||||
### Security considerations
|
||||
|
||||
None. This is presentational.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Chip is a `Surface(onClick = ...)` — natural focus + click target.
|
||||
- `semantics { contentDescription = "New posts available, tap to scroll to top" }` on the Surface.
|
||||
- Slide animation respects `LocalDensity`; no fixed-pixel hacks.
|
||||
- Defer keyboard shortcut (e.g. Home key) to a follow-up — out of scope.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** New events from relays → `DesktopRelaySubscriptionsCoordinator.consumeEvent` → 250ms bundler → `cacheEventStream.emitNewNotes` → `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<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`
|
||||
|
||||
```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?
|
||||
Reference in New Issue
Block a user