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
+61 -25
View File
@@ -4,15 +4,16 @@ import Animated, {
FadeInDown,
FadeOut,
interpolate,
type SharedValue,
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 {plural} from '@lingui/core/macro'
import {Plural, 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'
@@ -30,14 +31,33 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
*/
export function SeeNewPostsPill({
onPress: onPressInner,
count = 0,
topOffset = 0,
headerMode,
attached = false,
}: {
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
* headerOffset used by the feed list.
*/
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: l} = useLingui()
@@ -57,7 +77,6 @@ export function SeeNewPostsPill({
*/
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
@@ -92,29 +111,38 @@ export function SeeNewPostsPill({
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.
* Attached mode anchors the pill directly below its parent (the
* sticky tab bar), which is already column-width, so centering is
* trivial. Otherwise, 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
attached
? [
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: '100%' as const, paddingTop: 12},
]
: [a.absolute, a.w_full, a.align_center],
: IS_WEB
? [
a.fixed,
{
top,
left: '50%',
transform: [
{translateX: '-50%'},
{
translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0,
},
...a.scrollbar_offset.transform,
],
},
]
: [a.absolute, a.w_full, a.align_center, {top}],
{
top,
// Don't prevent scrolling in this area _except_ for in the pill itself
pointerEvents: 'box-none',
},
@@ -128,7 +156,11 @@ export function SeeNewPostsPill({
<AnimatedPressable
testID="seeNewPostsPill"
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`}
style={[
a.flex_row,
@@ -153,11 +185,15 @@ export function SeeNewPostsPill({
onPointerLeave={onHoverOut}>
<SubtleHover hover={hovered} style={[a.rounded_full]} />
<ArrowUpIcon
size="xs"
size="sm"
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>
{count > 0 ? (
<Plural value={count} one="# new post" other="# new posts" />
) : (
<Trans>See new posts</Trans>
)}
</Text>
</AnimatedPressable>
</Animated.View>
+38
View File
@@ -416,6 +416,44 @@ export function usePostFeedQuery(
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) {
if (!page) {
return false
+62 -13
View File
@@ -10,7 +10,11 @@ import {View} from 'react-native'
import {type AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
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 {DISCOVER_FEED_URI, VIDEO_FEED_URIS} from '#/lib/constants'
@@ -29,17 +33,22 @@ import {
} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util'
import {useSession} from '#/state/session'
import {Portal as HomeHeaderPortal} from '#/view/com/home/HomeHeaderPortal'
import {PostFeed} from '#/view/com/posts/PostFeed'
import {FAB} from '#/view/com/util/fab/FAB'
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 {
MainScrollProvider,
useHomeHeaderMode,
} from '#/view/com/util/MainScrollProvider'
import {useBreakpoints, 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 {Portal} from '#/components/Portal'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {IS_NATIVE, IS_WEB} from '#/env'
const POLL_FREQ = 60e3 // 60sec
@@ -66,6 +75,9 @@ export function FeedPage({
}) {
const ax = useAnalytics()
const {hasSession, currentAccount} = useSession()
const headerMode = useHomeHeaderMode()
const isScreenFocused = useIsFocused()
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const navigation = useNavigation<NavigationProp<AllNavigatorParams>>()
const queryClient = useQueryClient()
@@ -81,6 +93,11 @@ export function FeedPage({
* by pressing the pill or by scrolling up on their own.
*/
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 setHomeBadge = useSetHomeBadge()
const isVideoFeed = useMemo(() => {
@@ -118,8 +135,9 @@ export function FeedPage({
}
}, [isScrolledDown])
const onPositionRestored = useCallback(() => {
const onPositionRestored = useCallback((unseenCount: number) => {
setShowResumePill(true)
setNewPostsCount(unseenCount)
}, [])
const onSoftReset = useCallback(() => {
@@ -204,9 +222,17 @@ export function FeedPage({
* 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
* 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 =
isFollowingFeed && (hasNew || (showResumePill && isScrolledDown))
isFollowingFeed &&
isPageFocused &&
isScreenFocused &&
(hasNew || (showResumePill && isScrolledDown))
return (
<View
testID={testID}
@@ -220,10 +246,15 @@ export function FeedPage({
feed={feed}
feedParams={feedParams}
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}
onScrolledDownChange={setIsScrolledDown}
onHasNew={setHasNew}
onHasNewCount={isFollowingFeed ? setNewPostsCount : undefined}
onPositionRestored={onPositionRestored}
renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed}
@@ -233,12 +264,30 @@ export function FeedPage({
/>
</FeedFeedbackProvider>
</MainScrollProvider>
{showSeeNewPostsPill && (
<SeeNewPostsPill
onPress={onPressSeeNewPosts}
topOffset={headerOffset}
/>
)}
{showSeeNewPostsPill &&
(IS_WEB && gtMobile ? (
/*
* Larger web layouts anchor the pill inside the home header,
* directly below the sticky tab bar.
*/
<HomeHeaderPortal>
<SeeNewPostsPill
attached
count={newPostsCount}
onPress={onPressSeeNewPosts}
headerMode={headerMode}
/>
</HomeHeaderPortal>
) : (
<Portal>
<SeeNewPostsPill
count={newPostsCount}
onPress={onPressSeeNewPosts}
topOffset={headerOffset}
headerMode={headerMode}
/>
</Portal>
))}
{(isScrolledDown || (hasNew && !isFollowingFeed)) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
@@ -7,6 +7,7 @@ import {HITSLOP_10} from '#/lib/constants'
import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
import {HomeHeaderLayoutMobile} from '#/view/com/home/HomeHeaderLayoutMobile'
import {Outlet as HomeHeaderPortalOutlet} from '#/view/com/home/HomeHeaderPortal'
import {Logo} from '#/view/icons/Logo'
import {useLogoVariant} from '#/view/icons/useLogoVariant'
import {atoms as a, useBreakpoints, useGutters, useTheme} from '#/alf'
@@ -85,6 +86,7 @@ function HomeHeaderLayoutDesktopAndTablet({
headerHeight.set(e.nativeEvent.layout.height)
}}>
{children}
<HomeHeaderPortalOutlet />
</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 FeedPostSliceItem,
pollLatest,
pollLatestCount,
RQKEY,
usePostFeedQuery,
} from '#/state/queries/post-feed'
@@ -248,6 +249,7 @@ let PostFeed = ({
scrollElRef,
onScrolledDownChange,
onHasNew,
onHasNewCount,
onPositionRestored,
renderEmptyState,
renderEndOfFeed,
@@ -272,12 +274,18 @@ let PostFeed = ({
disablePoll?: boolean
scrollElRef?: ListRef
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
/**
* 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
renderEndOfFeed?: () => React.ReactElement
testID?: string
@@ -369,7 +377,17 @@ let PostFeed = ({
}
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) {
void refetch()
} else {
@@ -847,13 +865,21 @@ let PostFeed = ({
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.
* Only announce the restore (which surfaces the "N new posts" pill)
* if there are posts above that the user hasn't seen - i.e. rows
* between the fresh head and the newest post they'd seen last time.
*/
const headUri = getHeadUri(feedItems)
if (headUri && headUri !== seenHeadUriRef.current) {
onPositionRestored?.()
let unseenCount = 0
for (const row of feedItems.slice(0, index)) {
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 (
isError ||
+8 -5
View File
@@ -28,6 +28,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {FeedPage} from '#/view/com/feeds/FeedPage'
import {HomeHeader} from '#/view/com/home/HomeHeader'
import {Provider as HomeHeaderPortalProvider} from '#/view/com/home/HomeHeaderPortal'
import {
Pager,
type PagerRef,
@@ -88,11 +89,13 @@ export function HomeScreen(props: Props) {
return (
<Layout.Screen testID="HomeScreen" noInsetTop={IS_LIQUID_GLASS}>
<HomeHeaderModeProvider>
<HomeScreenReady
{...props}
preferences={preferences}
pinnedFeedInfos={pinnedFeedInfos}
/>
<HomeHeaderPortalProvider>
<HomeScreenReady
{...props}
preferences={preferences}
pinnedFeedInfos={pinnedFeedInfos}
/>
</HomeHeaderPortalProvider>
</HomeHeaderModeProvider>
</Layout.Screen>
)