Files
bsky-social-app/src/screens/Profile/Sections/Feed.tsx
T
Samuel Newman 4c374eb616 Add animated scroll indicator insets to PostFeed
Track scroll position in PostFeed and use it to dynamically adjust iOS scroll
indicator insets so they follow the collapsing header. Gate the scrollY-based
adjustment behind an adjustScrollIndicators prop so it only activates on
profile pages. Thread collapsedHeaderHeight from PagerWithHeader through
ProfileFeedSection to PostFeed to replace the previous hardcoded minimum.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:46:16 +03:00

141 lines
4.2 KiB
TypeScript

import {useCallback, useEffect, useImperativeHandle, useState} from 'react'
import {findNodeHandle, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {
type FeedDescriptor,
RQKEY as FEED_RQKEY,
} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util'
import {PostFeed} from '#/view/com/posts/PostFeed'
import {
EmptyState,
type EmptyStateButtonProps,
} from '#/view/com/util/EmptyState'
import {type ListRef} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {atoms as a, ios, useTheme} from '#/alf'
import {EditBig_Stroke1_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig'
import {Text} from '#/components/Typography'
import {IS_IOS, IS_NATIVE} from '#/env'
import {type SectionRef} from './types'
interface FeedSectionProps {
ref?: React.Ref<SectionRef>
feed: FeedDescriptor
headerHeight: number
collapsedHeaderHeight: number
isFocused: boolean
scrollElRef: ListRef
ignoreFilterFor?: string
setScrollViewTag: (tag: number | null) => void
emptyStateMessage?: string
emptyStateButton?: EmptyStateButtonProps
emptyStateIcon?: React.ComponentType<any> | React.ReactElement
}
export function ProfileFeedSection({
ref,
feed,
headerHeight,
collapsedHeaderHeight,
isFocused,
scrollElRef,
ignoreFilterFor,
setScrollViewTag,
emptyStateMessage,
emptyStateButton,
emptyStateIcon,
}: FeedSectionProps) {
const {_} = useLingui()
const queryClient = useQueryClient()
const [hasNew, setHasNew] = useState(false)
const [isScrolledDown, setIsScrolledDown] = useState(false)
const shouldUseAdjustedNumToRender = feed.endsWith('posts_and_author_threads')
const isVideoFeed = IS_NATIVE && feed.endsWith('posts_with_video')
const adjustedInitialNumToRender = useInitialNumToRender({
screenHeightOffset: headerHeight,
})
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: IS_NATIVE,
offset: -headerHeight,
})
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
setHasNew(false)
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderPostsEmpty = useCallback(() => {
return (
<View style={[a.flex_1, a.justify_center, a.align_center]}>
<EmptyState
style={{width: '100%'}}
icon={emptyStateIcon || EditIcon}
iconSize="3xl"
message={emptyStateMessage || _(msg`No posts yet`)}
button={emptyStateButton}
/>
</View>
)
}, [_, emptyStateButton, emptyStateIcon, emptyStateMessage])
useEffect(() => {
if (IS_IOS && isFocused && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [isFocused, scrollElRef, setScrollViewTag])
return (
<View>
<PostFeed
testID="postsFeed"
enabled={isFocused}
feed={feed}
scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
progressViewOffset={ios(0)}
renderEndOfFeed={isVideoFeed ? undefined : ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
initialNumToRender={
shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
}
isVideoFeed={isVideoFeed}
adjustScrollIndicators
collapsedHeaderHeight={collapsedHeaderHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
/>
)}
</View>
)
}
function ProfileEndOfFeed() {
const t = useTheme()
return (
<View
style={[a.w_full, a.py_5xl, a.border_t, t.atoms.border_contrast_medium]}>
<Text style={[t.atoms.text_contrast_medium, a.text_center]}>
<Trans>End of feed</Trans>
</Text>
</View>
)
}