diff --git a/src/components/feeds/SeeNewPostsPill.tsx b/src/components/feeds/SeeNewPostsPill.tsx index 53dfa30cdc..0c44463dd0 100644 --- a/src/components/feeds/SeeNewPostsPill.tsx +++ b/src/components/feeds/SeeNewPostsPill.tsx @@ -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 + /** + * 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({ 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}> - See new posts + {count > 0 ? ( + + ) : ( + See new posts + )} diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 959ed81c28..95b51b69c7 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -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 { + 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 diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 06772ab86a..9b58293ff3 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -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>() 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 ( - {showSeeNewPostsPill && ( - - )} + {showSeeNewPostsPill && + (IS_WEB && gtMobile ? ( + /* + * Larger web layouts anchor the pill inside the home header, + * directly below the sticky tab bar. + */ + + + + ) : ( + + + + ))} {(isScrolledDown || (hasNew && !isFollowingFeed)) && ( {children} + ) diff --git a/src/view/com/home/HomeHeaderPortal.ts b/src/view/com/home/HomeHeaderPortal.ts new file mode 100644 index 0000000000..203f93b5bb --- /dev/null +++ b/src/view/com/home/HomeHeaderPortal.ts @@ -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 diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index cee5475ca5..ef86dbf06e 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -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 || diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index d3495bbd04..b9f6404ce0 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -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 ( - + + + )