Add "See more posts" button

This commit is contained in:
Alex Benzer
2026-08-13 15:44:22 -07:00
parent 6d8157ff62
commit 6dc630d4c9
6 changed files with 291 additions and 17 deletions
+1 -7
View File
@@ -199,17 +199,11 @@ export type Events = {
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
}
'feed:resume:toggle': {
/** The state the user switched the setting to. */
enabled: boolean
}
'feed:resume:seeNewPostsPressed': {}
'feed:positionRestored': {
/**
* `restored` means we scrolled to the anchor post; `not-found` means the
* anchor never showed up within the page cap; `aborted` means the user
* scrolled or refreshed before the restore could complete.
*/
outcome: 'restored' | 'not-found' | 'aborted'
/** Number of rows above the restored anchor, when restored. */
depth?: number
pagesFetched: number
}
+166
View File
@@ -0,0 +1,166 @@
import {useCallback} from 'react'
import {Pressable, View} from 'react-native'
import Animated, {
FadeInDown,
FadeOut,
interpolate,
useAnimatedStyle,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {scheduleOnRN} from 'react-native-worklets'
import {Trans, useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useHomeHeaderMode} from '#/view/com/util/MainScrollProvider'
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon} from '#/components/icons/Arrow'
import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
/**
* Floating pill shown near the top of a feed after the last read position was
* restored, telling the user there are newer posts loaded above. Pressing it
* scrolls to the top of the feed.
*/
export function SeeNewPostsPill({
onPress: onPressInner,
topOffset = 0,
}: {
onPress: () => void
/**
* Height of the floating feed header the pill must clear. Pass the same
* headerOffset used by the feed list.
*/
topOffset?: number
}) {
const t = useTheme()
const {t: l} = useLingui()
const playHaptic = useHaptics()
const {gtMobile} = useBreakpoints()
const {centerColumnOffset} = useLayoutBreakpoints()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
/*
* On mobile (and native) topOffset is the floating header height, so a
* small margin below it is enough. On larger web breakpoints the header
* offset is 0 but the tab bar is sticky, so clear its height instead.
*/
const top = topOffset > 0 ? topOffset + 12 : gtMobile ? 64 : 12
const headerMode = useHomeHeaderMode()
const {headerHeight} = useShellLayout()
const {top: topInset} = useSafeAreaInsets()
const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0
/*
* On mobile the floating header minimizes away as the user scrolls, so
* follow its translation to stay just below it - but stop at the safe
* area inset so the pill never sits under the status bar.
*/
const followHeaderStyle = useAnimatedStyle(() => {
if (topOffset === 0) {
return {transform: [{translateY: 0}]}
}
const translateY = interpolate(
headerMode.get(),
[0, 1],
[0, headerPinnedHeight - headerHeight.get()],
)
const minTop = topInset + 12
return {
transform: [{translateY: Math.max(translateY, minTop - top)}],
}
})
const onPress = useCallback(() => {
scheduleOnRN(playHaptic)
onPressInner?.()
}, [onPressInner, playHaptic])
return (
<View
style={[
a.z_20,
/*
* On web the pill is fixed and must be centered on the content
* column, not the viewport - the column shifts at some widths (nav
* rail, tablet offset), so mirror Layout's WebCenterBorders
* centering. On native the feed spans the screen, so a full-width
* absolute container can simply center its child.
*/
IS_WEB
? [
a.fixed,
{
left: '50%',
transform: [
{translateX: '-50%'},
{
translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0,
},
...a.scrollbar_offset.transform,
],
},
]
: [a.absolute, a.w_full, a.align_center],
{
top,
// Don't prevent scrolling in this area _except_ for in the pill itself
pointerEvents: 'box-none',
},
]}>
{/*
* The header-follow translation lives on its own wrapper because both
* the container (web centering) and the pressable (press scale) already
* use transforms of their own.
*/}
<Animated.View style={[followHeaderStyle, {pointerEvents: 'box-none'}]}>
<AnimatedPressable
testID="seeNewPostsPill"
accessibilityRole="button"
accessibilityLabel={l`See new posts`}
accessibilityHint={l`Scrolls to the top of the feed`}
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_xs,
a.rounded_full,
a.shadow_sm,
a.border,
a.px_md,
a.py_sm,
t.atoms.border_contrast_low,
{
backgroundColor: t.palette.primary_50,
pointerEvents: 'box-only',
},
]}
entering={FadeInDown.springify().damping(18).stiffness(220)}
exiting={FadeOut.duration(150)}
onPress={onPress}
onPointerEnter={onHoverIn}
onPointerLeave={onHoverOut}>
<SubtleHover hover={hovered} style={[a.rounded_full]} />
<ArrowUpIcon
size="xs"
style={[a.z_10, {color: t.palette.primary_600}]}
/>
<Text style={[a.z_10, a.font_bold, {color: t.palette.primary_600}]}>
<Trans>See new posts</Trans>
</Text>
</AnimatedPressable>
</Animated.View>
</View>
)
}
+13 -5
View File
@@ -32,25 +32,33 @@ export function useFollowingFeedResumeEnabled() {
/**
* Save the most recently viewed post in the Following feed as the anchor to
* restore to on next cold start.
* restore to on next cold start, along with the newest post the user has
* seen at the top of the feed (if known).
*/
export function saveFollowingFeedPosition(did: string, anchorUri: string) {
export function saveFollowingFeedPosition(
did: string,
anchorUri: string,
seenHeadUri?: string,
) {
account.set([did, 'followingFeedPosition'], {
anchorUri,
seenHeadUri,
savedAt: Date.now(),
})
}
/**
* Returns the saved anchor post URI, or undefined if nothing was saved or the
* Returns the saved feed position, or undefined if nothing was saved or the
* saved position has expired.
*/
export function getFollowingFeedPosition(did: string): string | undefined {
export function getFollowingFeedPosition(
did: string,
): {anchorUri: string; seenHeadUri?: string} | undefined {
const position = account.get([did, 'followingFeedPosition'])
if (!position || Date.now() - position.savedAt > MAX_POSITION_AGE) {
return undefined
}
return position.anchorUri
return position
}
export function clearFollowingFeedPosition(did: string) {
+6
View File
@@ -106,6 +106,12 @@ export type Account = {
followingFeedPosition?: {
anchorUri: string
savedAt: number
/**
* The newest post the user has actually seen at the top of the feed.
* Used after a restore to decide whether there is anything new above
* worth pointing out.
*/
seenHeadUri?: string
}
/**
+60 -2
View File
@@ -35,6 +35,7 @@ import {type ListMethods} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
import {useTheme} from '#/alf'
import {SeeNewPostsPill} from '#/components/feeds/SeeNewPostsPill'
import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
import {useAnalytics} from '#/analytics'
@@ -74,6 +75,13 @@ export function FeedPage({
const feedFeedback = useFeedFeedback(feedInfo, hasSession)
const scrollElRef = useRef<ListMethods>(null)
const [hasNew, setHasNew] = useState(false)
/**
* Whether to show the "See new posts" pill after the feed was restored to
* the last read position. Cleared once the user returns to the top, either
* by pressing the pill or by scrolling up on their own.
*/
const [showResumePill, setShowResumePill] = useState(false)
const wasScrolledDownRef = useRef(false)
const setHomeBadge = useSetHomeBadge()
const isVideoFeed = useMemo(() => {
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
@@ -97,6 +105,23 @@ export function FeedPage({
})
}, [headerOffset])
/*
* The pill renders only while scrolled down, but it should not come back
* on later scroll-downs, so clear it for good once the user has scrolled
* back up to the top.
*/
useEffect(() => {
if (isScrolledDown) {
wasScrolledDownRef.current = true
} else if (wasScrolledDownRef.current) {
setShowResumePill(false)
}
}, [isScrolledDown])
const onPositionRestored = useCallback(() => {
setShowResumePill(true)
}, [])
const onSoftReset = useCallback(() => {
const isScreenFocused =
getTabState(getRootNavigation(navigation).getState(), 'Home') ===
@@ -150,8 +175,34 @@ export function FeedPage({
})
}, [ax, scrollToTop, feed, queryClient, currentAccount])
/*
* In the restore case the posts above are already loaded, so pressing the
* pill only needs to scroll up. In the hasNew case they are not loaded
* yet, so it must refetch like the "Load new posts" button.
*/
const onPressSeeNewPosts = useCallback(() => {
setShowResumePill(false)
if (hasNew) {
onPressLoadLatest()
} else {
scrollToTop()
ax.metric('feed:resume:seeNewPostsPressed', {})
}
}, [ax, hasNew, onPressLoadLatest, scrollToTop])
const shouldPrefetch = IS_NATIVE && isPageAdjacent
const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI
const isFollowingFeed = feed === 'following'
/*
* On the Following feed the pill takes over signaling new posts from the
* LoadLatestBtn indicator, both when new posts arrive while reading
* (hasNew) and after restoring the last read position. Other feeds keep
* the LoadLatestBtn indicator. Only shown while scrolled down - once the
* top of the feed is in view the pill would just be noise, so it hides
* even if hasNew is still set.
*/
const showSeeNewPostsPill =
isFollowingFeed && isScrolledDown && (hasNew || showResumePill)
return (
<View
testID={testID}
@@ -169,6 +220,7 @@ export function FeedPage({
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
onHasNew={setHasNew}
onPositionRestored={onPositionRestored}
renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed}
headerOffset={headerOffset}
@@ -177,11 +229,17 @@ export function FeedPage({
/>
</FeedFeedbackProvider>
</MainScrollProvider>
{(isScrolledDown || hasNew) && (
{showSeeNewPostsPill && (
<SeeNewPostsPill
onPress={onPressSeeNewPosts}
topOffset={headerOffset}
/>
)}
{(isScrolledDown || (hasNew && !isFollowingFeed)) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
showIndicator={hasNew && !isFollowingFeed}
/>
)}
+45 -3
View File
@@ -224,6 +224,18 @@ const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
*/
const RESTORE_MAX_PAGES = 6
/**
* URI of the first post in the feed, i.e. the newest loaded post.
*/
function getHeadUri(feedItems: FeedRow[]): string | undefined {
for (const row of feedItems) {
if (row.type === 'sliceItem') {
return row.slice.items[row.indexInSlice].uri
}
}
return undefined
}
let PostFeed = ({
feed,
description,
@@ -236,6 +248,7 @@ let PostFeed = ({
scrollElRef,
onScrolledDownChange,
onHasNew,
onPositionRestored,
renderEmptyState,
renderEndOfFeed,
testID,
@@ -260,6 +273,11 @@ let PostFeed = ({
scrollElRef?: ListRef
onHasNew?: (v: boolean) => void
onScrolledDownChange?: (isScrolledDown: boolean) => void
/**
* Called after the feed successfully scrolled back to the last read
* position on cold start.
*/
onPositionRestored?: () => void
renderEmptyState: () => React.ReactElement
renderEndOfFeed?: () => React.ReactElement
testID?: string
@@ -379,13 +397,20 @@ let PostFeed = ({
* overwritten before we've scrolled to it.
*/
// oxlint-disable-next-line react/hook-use-state
const [restoreAnchorUri] = useState(() =>
const [restorePosition] = useState(() =>
feed === 'following' && resumeEnabled && currentAccount
? getFollowingFeedPosition(currentAccount.did)
: undefined,
)
const restoreAnchorUri = restorePosition?.anchorUri
const restorePendingRef = useRef(restoreAnchorUri != null)
const restoreScrollRetriesRef = useRef(0)
/**
* The newest post the user has actually seen at the top of the feed,
* carried over from the saved position and updated whenever the head post
* comes into view.
*/
const seenHeadUriRef = useRef(restorePosition?.seenHeadUri)
const myDid = currentAccount?.did || ''
const onPostCreated = useCallback(() => {
@@ -794,7 +819,8 @@ let PostFeed = ({
*/
if (
isScrolledDownRef.current ||
getFollowingFeedPosition(currentAccount.did) !== restoreAnchorUri
getFollowingFeedPosition(currentAccount.did)?.anchorUri !==
restoreAnchorUri
) {
restorePendingRef.current = false
ax.metric('feed:positionRestored', {
@@ -820,6 +846,15 @@ let PostFeed = ({
depth: index,
pagesFetched: data.pages.length,
})
/*
* Only announce the restore (which surfaces the "See new posts" pill)
* if the feed's head post is one the user hasn't seen - otherwise
* everything above the anchor was already read.
*/
const headUri = getHeadUri(feedItems)
if (headUri && headUri !== seenHeadUriRef.current) {
onPositionRestored?.()
}
} else if (
isError ||
!hasNextPage ||
@@ -846,6 +881,7 @@ let PostFeed = ({
scrollElRef,
headerOffset,
ax,
onPositionRestored,
])
const onScrollToIndexFailed = useCallback(
@@ -1166,9 +1202,14 @@ let PostFeed = ({
currentAccount &&
!restorePendingRef.current
) {
const uri = item.slice.items[item.indexInSlice].uri
if (uri === getHeadUri(feedItems)) {
seenHeadUriRef.current = uri
}
saveFollowingFeedPosition(
currentAccount.did,
item.slice.items[item.indexInSlice].uri,
uri,
seenHeadUriRef.current,
)
}
@@ -1283,6 +1324,7 @@ let PostFeed = ({
ax,
resumeEnabled,
currentAccount,
feedItems,
],
)