Adjust see more posts behavior again
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user