Adjust see more posts behavior again

This commit is contained in:
Alex Benzer
2026-08-13 19:58:02 -07:00
parent fd4cbcc362
commit a66f85340d
7 changed files with 218 additions and 52 deletions
+49 -13
View File
@@ -4,15 +4,16 @@ import Animated, {
FadeInDown, FadeInDown,
FadeOut, FadeOut,
interpolate, interpolate,
type SharedValue,
useAnimatedStyle, useAnimatedStyle,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {scheduleOnRN} from 'react-native-worklets' import {scheduleOnRN} from 'react-native-worklets'
import {Trans, useLingui} from '@lingui/react/macro' import {plural} from '@lingui/core/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {useHomeHeaderMode} from '#/view/com/util/MainScrollProvider'
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon} from '#/components/icons/Arrow' import {ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon} from '#/components/icons/Arrow'
@@ -30,14 +31,33 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
*/ */
export function SeeNewPostsPill({ export function SeeNewPostsPill({
onPress: onPressInner, onPress: onPressInner,
count = 0,
topOffset = 0, topOffset = 0,
headerMode,
attached = false,
}: { }: {
onPress: () => void onPress: () => void
/**
* Number of new posts above the user. Zero (unknown) falls back to a
* generic label.
*/
count?: number
/** /**
* Height of the floating feed header the pill must clear. Pass the same * Height of the floating feed header the pill must clear. Pass the same
* headerOffset used by the feed list. * headerOffset used by the feed list.
*/ */
topOffset?: number topOffset?: number
/**
* The home header's minimal-shell mode, passed in as a prop because the
* pill renders in a Portal outside HomeHeaderModeProvider.
*/
headerMode: SharedValue<number>
/**
* When set, the pill is rendered inside the home header (via
* HomeHeaderPortal) and anchors itself directly below the sticky tab bar
* instead of using fixed viewport positioning. Larger web layouts only.
*/
attached?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -57,7 +77,6 @@ export function SeeNewPostsPill({
*/ */
const top = topOffset > 0 ? topOffset + 12 : gtMobile ? 64 : 12 const top = topOffset > 0 ? topOffset + 12 : gtMobile ? 64 : 12
const headerMode = useHomeHeaderMode()
const {headerHeight} = useShellLayout() const {headerHeight} = useShellLayout()
const {top: topInset} = useSafeAreaInsets() const {top: topInset} = useSafeAreaInsets()
const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0 const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0
@@ -92,16 +111,26 @@ export function SeeNewPostsPill({
style={[ style={[
a.z_20, a.z_20,
/* /*
* On web the pill is fixed and must be centered on the content * Attached mode anchors the pill directly below its parent (the
* column, not the viewport - the column shifts at some widths (nav * sticky tab bar), which is already column-width, so centering is
* rail, tablet offset), so mirror Layout's WebCenterBorders * trivial. Otherwise, on web the pill is fixed and must be centered
* centering. On native the feed spans the screen, so a full-width * on the content column, not the viewport - the column shifts at
* absolute container can simply center its child. * 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 attached
? [
a.absolute,
a.w_full,
a.align_center,
{top: '100%' as const, paddingTop: 12},
]
: IS_WEB
? [ ? [
a.fixed, a.fixed,
{ {
top,
left: '50%', left: '50%',
transform: [ transform: [
{translateX: '-50%'}, {translateX: '-50%'},
@@ -112,9 +141,8 @@ export function SeeNewPostsPill({
], ],
}, },
] ]
: [a.absolute, a.w_full, a.align_center], : [a.absolute, a.w_full, a.align_center, {top}],
{ {
top,
// Don't prevent scrolling in this area _except_ for in the pill itself // Don't prevent scrolling in this area _except_ for in the pill itself
pointerEvents: 'box-none', pointerEvents: 'box-none',
}, },
@@ -128,7 +156,11 @@ export function SeeNewPostsPill({
<AnimatedPressable <AnimatedPressable
testID="seeNewPostsPill" testID="seeNewPostsPill"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={l`See new posts`} accessibilityLabel={
count > 0
? plural(count, {one: '# new post', other: '# new posts'})
: l`See new posts`
}
accessibilityHint={l`Scrolls to the top of the feed`} accessibilityHint={l`Scrolls to the top of the feed`}
style={[ style={[
a.flex_row, a.flex_row,
@@ -153,11 +185,15 @@ export function SeeNewPostsPill({
onPointerLeave={onHoverOut}> onPointerLeave={onHoverOut}>
<SubtleHover hover={hovered} style={[a.rounded_full]} /> <SubtleHover hover={hovered} style={[a.rounded_full]} />
<ArrowUpIcon <ArrowUpIcon
size="xs" size="sm"
style={[a.z_10, {color: t.palette.primary_600}]} style={[a.z_10, {color: t.palette.primary_600}]}
/> />
<Text style={[a.z_10, a.font_bold, {color: t.palette.primary_600}]}> <Text style={[a.z_10, a.font_bold, {color: t.palette.primary_600}]}>
{count > 0 ? (
<Plural value={count} one="# new post" other="# new posts" />
) : (
<Trans>See new posts</Trans> <Trans>See new posts</Trans>
)}
</Text> </Text>
</AnimatedPressable> </AnimatedPressable>
</Animated.View> </Animated.View>
+38
View File
@@ -416,6 +416,44 @@ export function usePostFeedQuery(
return query return query
} }
/**
* Like pollLatest, but returns how many new posts would appear above the
* current head of the feed, capped at the size of a single fetch. Used by
* the Following feed's "N new posts" pill.
*/
export async function pollLatestCount(
page: FeedPage | undefined,
): Promise<number> {
if (!page) {
return 0
}
if (AppState.currentState !== 'active') {
return 0
}
const headUri = page.slices[0]?.items[0]?.uri
if (!headUri) {
return 0
}
logger.debug('usePostFeedQuery: pollLatestCount')
const res = await page.api.fetch({cursor: undefined, limit: 30})
const newPosts = []
for (const post of res.feed) {
if (post.post.uri === headUri) {
break
}
newPosts.push(post)
}
if (newPosts.length === 0) {
return 0
}
const slices = page.tuner.tune(newPosts, {
dryRun: true,
})
return slices.reduce((count, slice) => count + slice.items.length, 0)
}
export async function pollLatest(page: FeedPage | undefined) { export async function pollLatest(page: FeedPage | undefined) {
if (!page) { if (!page) {
return false return false
+58 -9
View File
@@ -10,7 +10,11 @@ import {View} from 'react-native'
import {type AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' import {type AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {type NavigationProp, useNavigation} from '@react-navigation/native' import {
type NavigationProp,
useIsFocused,
useNavigation,
} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {DISCOVER_FEED_URI, VIDEO_FEED_URIS} from '#/lib/constants' import {DISCOVER_FEED_URI, VIDEO_FEED_URIS} from '#/lib/constants'
@@ -29,17 +33,22 @@ import {
} from '#/state/queries/post-feed' } from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {Portal as HomeHeaderPortal} from '#/view/com/home/HomeHeaderPortal'
import {PostFeed} from '#/view/com/posts/PostFeed' import {PostFeed} from '#/view/com/posts/PostFeed'
import {FAB} from '#/view/com/util/fab/FAB' import {FAB} from '#/view/com/util/fab/FAB'
import {type ListMethods} from '#/view/com/util/List' import {type ListMethods} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider' import {
import {useTheme} from '#/alf' MainScrollProvider,
useHomeHeaderMode,
} from '#/view/com/util/MainScrollProvider'
import {useBreakpoints, useTheme} from '#/alf'
import {SeeNewPostsPill} from '#/components/feeds/SeeNewPostsPill' import {SeeNewPostsPill} from '#/components/feeds/SeeNewPostsPill'
import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig' import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
import {Portal} from '#/components/Portal'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
const POLL_FREQ = 60e3 // 60sec const POLL_FREQ = 60e3 // 60sec
@@ -66,6 +75,9 @@ export function FeedPage({
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const headerMode = useHomeHeaderMode()
const isScreenFocused = useIsFocused()
const {gtMobile} = useBreakpoints()
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp<AllNavigatorParams>>() const navigation = useNavigation<NavigationProp<AllNavigatorParams>>()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -81,6 +93,11 @@ export function FeedPage({
* by pressing the pill or by scrolling up on their own. * by pressing the pill or by scrolling up on their own.
*/ */
const [showResumePill, setShowResumePill] = useState(false) const [showResumePill, setShowResumePill] = useState(false)
/**
* How many unseen posts are above the user, from either the restore path
* or the new-posts poll. Shown in the pill.
*/
const [newPostsCount, setNewPostsCount] = useState(0)
const wasScrolledDownRef = useRef(false) const wasScrolledDownRef = useRef(false)
const setHomeBadge = useSetHomeBadge() const setHomeBadge = useSetHomeBadge()
const isVideoFeed = useMemo(() => { const isVideoFeed = useMemo(() => {
@@ -118,8 +135,9 @@ export function FeedPage({
} }
}, [isScrolledDown]) }, [isScrolledDown])
const onPositionRestored = useCallback(() => { const onPositionRestored = useCallback((unseenCount: number) => {
setShowResumePill(true) setShowResumePill(true)
setNewPostsCount(unseenCount)
}, []) }, [])
const onSoftReset = useCallback(() => { const onSoftReset = useCallback(() => {
@@ -204,9 +222,17 @@ export function FeedPage({
* reaching the top fulfills it and the pill hides. When hasNew is set the * reaching the top fulfills it and the pill hides. When hasNew is set the
* posts are not loaded yet, so the pill persists at any scroll position * posts are not loaded yet, so the pill persists at any scroll position
* until it is pressed or the feed is refreshed. * until it is pressed or the feed is refreshed.
*
* Gated on isPageFocused (the pager tab) and isScreenFocused (the Home
* screen itself) because the pill renders in a Portal outside this
* page's subtree, so it would otherwise stay visible on other feed tabs
* and on entirely different screens like Notifications.
*/ */
const showSeeNewPostsPill = const showSeeNewPostsPill =
isFollowingFeed && (hasNew || (showResumePill && isScrolledDown)) isFollowingFeed &&
isPageFocused &&
isScreenFocused &&
(hasNew || (showResumePill && isScrolledDown))
return ( return (
<View <View
testID={testID} testID={testID}
@@ -220,10 +246,15 @@ export function FeedPage({
feed={feed} feed={feed}
feedParams={feedParams} feedParams={feedParams}
pollInterval={POLL_FREQ} pollInterval={POLL_FREQ}
disablePoll={hasNew || !isPageFocused} /*
* On the Following feed, keep polling after hasNew so the pill's
* new-post count stays up to date.
*/
disablePoll={!isPageFocused || (hasNew && !isFollowingFeed)}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown} onScrolledDownChange={setIsScrolledDown}
onHasNew={setHasNew} onHasNew={setHasNew}
onHasNewCount={isFollowingFeed ? setNewPostsCount : undefined}
onPositionRestored={onPositionRestored} onPositionRestored={onPositionRestored}
renderEmptyState={renderEmptyState} renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed} renderEndOfFeed={renderEndOfFeed}
@@ -233,12 +264,30 @@ export function FeedPage({
/> />
</FeedFeedbackProvider> </FeedFeedbackProvider>
</MainScrollProvider> </MainScrollProvider>
{showSeeNewPostsPill && ( {showSeeNewPostsPill &&
(IS_WEB && gtMobile ? (
/*
* Larger web layouts anchor the pill inside the home header,
* directly below the sticky tab bar.
*/
<HomeHeaderPortal>
<SeeNewPostsPill <SeeNewPostsPill
attached
count={newPostsCount}
onPress={onPressSeeNewPosts}
headerMode={headerMode}
/>
</HomeHeaderPortal>
) : (
<Portal>
<SeeNewPostsPill
count={newPostsCount}
onPress={onPressSeeNewPosts} onPress={onPressSeeNewPosts}
topOffset={headerOffset} topOffset={headerOffset}
headerMode={headerMode}
/> />
)} </Portal>
))}
{(isScrolledDown || (hasNew && !isFollowingFeed)) && ( {(isScrolledDown || (hasNew && !isFollowingFeed)) && (
<LoadLatestBtn <LoadLatestBtn
onPress={onPressLoadLatest} onPress={onPressLoadLatest}
@@ -7,6 +7,7 @@ import {HITSLOP_10} from '#/lib/constants'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {HomeHeaderLayoutMobile} from '#/view/com/home/HomeHeaderLayoutMobile' import {HomeHeaderLayoutMobile} from '#/view/com/home/HomeHeaderLayoutMobile'
import {Outlet as HomeHeaderPortalOutlet} from '#/view/com/home/HomeHeaderPortal'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {useLogoVariant} from '#/view/icons/useLogoVariant' import {useLogoVariant} from '#/view/icons/useLogoVariant'
import {atoms as a, useBreakpoints, useGutters, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useGutters, useTheme} from '#/alf'
@@ -85,6 +86,7 @@ function HomeHeaderLayoutDesktopAndTablet({
headerHeight.set(e.nativeEvent.layout.height) headerHeight.set(e.nativeEvent.layout.height)
}}> }}>
{children} {children}
<HomeHeaderPortalOutlet />
</Layout.Center> </Layout.Center>
</> </>
) )
+12
View File
@@ -0,0 +1,12 @@
import {createPortalGroup} from '#/components/Portal'
/**
* Renders into the home header on larger web layouts, directly below the
* sticky tab bar. Lets the Following feed pin its "N new posts" pill under
* the bar regardless of scroll position.
*/
const group = createPortalGroup()
export const Provider = group.Provider
export const Outlet = group.Outlet
export const Portal = group.Portal
+35 -9
View File
@@ -54,6 +54,7 @@ import {
type FeedPostSlice, type FeedPostSlice,
type FeedPostSliceItem, type FeedPostSliceItem,
pollLatest, pollLatest,
pollLatestCount,
RQKEY, RQKEY,
usePostFeedQuery, usePostFeedQuery,
} from '#/state/queries/post-feed' } from '#/state/queries/post-feed'
@@ -248,6 +249,7 @@ let PostFeed = ({
scrollElRef, scrollElRef,
onScrolledDownChange, onScrolledDownChange,
onHasNew, onHasNew,
onHasNewCount,
onPositionRestored, onPositionRestored,
renderEmptyState, renderEmptyState,
renderEndOfFeed, renderEndOfFeed,
@@ -272,12 +274,18 @@ let PostFeed = ({
disablePoll?: boolean disablePoll?: boolean
scrollElRef?: ListRef scrollElRef?: ListRef
onHasNew?: (v: boolean) => void onHasNew?: (v: boolean) => void
/**
* When provided, the new-posts poll also reports how many posts would
* appear above the current head of the feed (capped at one fetch).
*/
onHasNewCount?: (count: number) => void
onScrolledDownChange?: (isScrolledDown: boolean) => void onScrolledDownChange?: (isScrolledDown: boolean) => void
/** /**
* Called after the feed successfully scrolled back to the last read * Called after the feed successfully scrolled back to the last read
* position on cold start. * position on cold start and there are posts above that the user has not
* seen, with the number of unseen posts.
*/ */
onPositionRestored?: () => void onPositionRestored?: (unseenCount: number) => void
renderEmptyState: () => React.ReactElement renderEmptyState: () => React.ReactElement
renderEndOfFeed?: () => React.ReactElement renderEndOfFeed?: () => React.ReactElement
testID?: string testID?: string
@@ -369,7 +377,17 @@ let PostFeed = ({
} }
try { try {
if (await pollLatest(data.pages[0])) { if (onHasNewCount) {
const count = await pollLatestCount(data.pages[0])
if (count > 0) {
if (isEmpty) {
void refetch()
} else {
onHasNew(true)
onHasNewCount(count)
}
}
} else if (await pollLatest(data.pages[0])) {
if (isEmpty) { if (isEmpty) {
void refetch() void refetch()
} else { } else {
@@ -847,13 +865,21 @@ let PostFeed = ({
pagesFetched: data.pages.length, pagesFetched: data.pages.length,
}) })
/* /*
* Only announce the restore (which surfaces the "See new posts" pill) * Only announce the restore (which surfaces the "N new posts" pill)
* if the feed's head post is one the user hasn't seen - otherwise * if there are posts above that the user hasn't seen - i.e. rows
* everything above the anchor was already read. * between the fresh head and the newest post they'd seen last time.
*/ */
const headUri = getHeadUri(feedItems) let unseenCount = 0
if (headUri && headUri !== seenHeadUriRef.current) { for (const row of feedItems.slice(0, index)) {
onPositionRestored?.() if (row.type !== 'sliceItem') continue
const uri = row.slice.items[row.indexInSlice].uri
if (uri === seenHeadUriRef.current) {
break
}
unseenCount++
}
if (unseenCount > 0) {
onPositionRestored?.(unseenCount)
} }
} else if ( } else if (
isError || isError ||
+3
View File
@@ -28,6 +28,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {FeedPage} from '#/view/com/feeds/FeedPage' import {FeedPage} from '#/view/com/feeds/FeedPage'
import {HomeHeader} from '#/view/com/home/HomeHeader' import {HomeHeader} from '#/view/com/home/HomeHeader'
import {Provider as HomeHeaderPortalProvider} from '#/view/com/home/HomeHeaderPortal'
import { import {
Pager, Pager,
type PagerRef, type PagerRef,
@@ -88,11 +89,13 @@ export function HomeScreen(props: Props) {
return ( return (
<Layout.Screen testID="HomeScreen" noInsetTop={IS_LIQUID_GLASS}> <Layout.Screen testID="HomeScreen" noInsetTop={IS_LIQUID_GLASS}>
<HomeHeaderModeProvider> <HomeHeaderModeProvider>
<HomeHeaderPortalProvider>
<HomeScreenReady <HomeScreenReady
{...props} {...props}
preferences={preferences} preferences={preferences}
pinnedFeedInfos={pinnedFeedInfos} pinnedFeedInfos={pinnedFeedInfos}
/> />
</HomeHeaderPortalProvider>
</HomeHeaderModeProvider> </HomeHeaderModeProvider>
</Layout.Screen> </Layout.Screen>
) )