diff --git a/src/components/ProfileBadges.tsx b/src/components/ProfileBadges.tsx index b9f63084e..d121ca86f 100644 --- a/src/components/ProfileBadges.tsx +++ b/src/components/ProfileBadges.tsx @@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react/macro' import {HITSLOP_20} from '#/lib/constants' import {useProfileShadow} from '#/state/cache/profile-shadow' import {type Shadow} from '#/state/cache/types' +import {useIsPdsProfileViewable} from '#/state/pds-viewability' import { usePdsLabelEnabled, usePdsLabelHideBskyPds, @@ -95,7 +96,10 @@ export function ProfileBadgesFromProfileShadow({ const hideBskyPds = usePdsLabelHideBskyPds() const isBskyHandle = !!shadowed.handle && shadowed.handle.endsWith('.bsky.social') - const shouldResolvePds = pdsLabelEnabled && !(hideBskyPds && isBskyHandle) + const shouldShowPdsCandidate = + pdsLabelEnabled && !(hideBskyPds && isBskyHandle) + const isPdsProfileViewable = useIsPdsProfileViewable(shadowed.did) + const shouldResolvePds = shouldShowPdsCandidate && isPdsProfileViewable const {data: pdsData, isLoading: isPdsLoading} = usePdsLabelQuery( shouldResolvePds ? shadowed.did : undefined, ) @@ -110,8 +114,7 @@ export function ProfileBadgesFromProfileShadow({ } = useAlf() const showPdsBadge = - shouldResolvePds && - (isPdsLoading || (!!pdsData && !(hideBskyPds && pdsData.isBsky))) + shouldShowPdsCandidate && (!pdsData || !(hideBskyPds && pdsData.isBsky)) const isBetaBadgeVisible = useIsBetaBadgeVisible(shadowed) && showBetaBadge const badgeVisibility = [ @@ -162,7 +165,7 @@ export function ProfileBadgesFromProfileShadow({ void + +export type PdsViewabilityStore = ReturnType + +export function createPdsViewabilityStore() { + const viewableDids = new Set() + const listenersByDid = new Map>() + + return { + markViewable(dids: Iterable) { + for (const did of dids) { + if (viewableDids.has(did)) continue + + viewableDids.add(did) + listenersByDid.get(did)?.forEach(listener => listener()) + listenersByDid.delete(did) + } + }, + isViewable(did: string) { + return viewableDids.has(did) + }, + subscribe(did: string, listener: Listener) { + let listeners = listenersByDid.get(did) + if (!listeners) { + listeners = new Set() + listenersByDid.set(did, listeners) + } + listeners.add(listener) + + return () => { + listeners.delete(listener) + if (listeners.size === 0) listenersByDid.delete(did) + } + }, + } +} + +const PdsViewabilityContext = createContext(null) + +export function PdsViewabilityProvider({ + children, + store, +}: PropsWithChildren<{store: PdsViewabilityStore}>) { + return ( + + {children} + + ) +} + +/** + * Profiles outside a virtualized feed resolve immediately. Inside a feed, a + * profile becomes eligible once its row has met the list's viewability rules. + */ +export function useIsPdsProfileViewable(did: string) { + const store = useContext(PdsViewabilityContext) + const subscribe = useCallback( + (listener: Listener) => store?.subscribe(did, listener) ?? (() => {}), + [did, store], + ) + const getSnapshot = useCallback( + () => store?.isViewable(did) ?? true, + [did, store], + ) + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} diff --git a/src/state/queries/pds-label.ts b/src/state/queries/pds-label.ts index c2e3d2d07..742c47fa6 100644 --- a/src/state/queries/pds-label.ts +++ b/src/state/queries/pds-label.ts @@ -1,6 +1,7 @@ import {useQuery} from '@tanstack/react-query' import {useFaviconService} from '#/state/preferences/favicon-service' +import {GCTIME} from '#/state/queries' import { getFaviconServiceUrl, getPdsFallbackFaviconUrl, @@ -8,11 +9,13 @@ import { isBskyPdsUrl, } from '#/state/queries/pds-label.util' import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' +import {createQueryKey} from '#/state/queries/util' export {getPdsFallbackFaviconUrl, isBridgedPdsUrl, isBskyPdsUrl} export const RQKEY_ROOT = 'pds-label' -export const RQKEY = (did: string) => [RQKEY_ROOT, did] +export const RQKEY = (did: string) => + createQueryKey(RQKEY_ROOT, {did}, {persistedVersion: 1}) export function usePdsLabelQuery(did: string | undefined) { return useQuery({ @@ -30,6 +33,7 @@ export function usePdsLabelQuery(did: string | undefined) { enabled: !!did, subscribed: !!did, staleTime: 1000 * 60 * 60, // 1 hour + gcTime: GCTIME.INFINITY, }) } diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 589ff9148..1ca286b92 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -39,6 +39,10 @@ import {logger} from '#/logger' import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' import {listenPostCreated} from '#/state/events' import {useFeedFeedbackContext} from '#/state/feed-feedback' +import { + createPdsViewabilityStore, + PdsViewabilityProvider, +} from '#/state/pds-viewability' import {useDisableComposerPrompt} from '#/state/preferences/disable-composer-prompt' import {useHideUnreplyablePosts} from '#/state/preferences/hide-unreplyable-posts' import {useRepostCarouselEnabled} from '#/state/preferences/repost-carousel-enabled' @@ -54,7 +58,11 @@ import { RQKEY, usePostFeedQuery, } from '#/state/queries/post-feed' -import {truncateAndInvalidate} from '#/state/queries/util' +import { + embedViewRecordToPostView, + getEmbeddedPost, + truncateAndInvalidate, +} from '#/state/queries/util' import {useSession} from '#/state/session' import {useProgressGuide} from '#/state/shell/progress-guide' import {useSelectedFeed} from '#/state/shell/selected-feed' @@ -191,6 +199,29 @@ type FeedRow = key: string } +function collectPdsProfileDids( + post: AppBskyFeedDefs.PostView, + dids = new Set(), + depth = 0, +) { + dids.add(post.author.did) + + // Quote embeds can themselves contain another quote. Bound recursion to + // malformed or unexpectedly deep data while covering real nested quotes. + if (depth < 4) { + const embeddedPost = getEmbeddedPost(post.embed) + if (embeddedPost) { + collectPdsProfileDids( + embedViewRecordToPostView(embeddedPost), + dids, + depth + 1, + ) + } + } + + return dids +} + type FeedPostSliceOrGroup = | (FeedPostSlice & { isRepostSlice?: false @@ -341,6 +372,7 @@ let PostFeed = ({ const {currentAccount, hasSession} = useSession() const initialNumToRender = useInitialNumToRender() const feedFeedback = useFeedFeedbackContext() + const pdsViewabilityStore = useMemo(() => createPdsViewabilityStore(), []) const [isPTRing, setIsPTRing] = useState(false) // eslint-disable-next-line react-hooks/purity const lastFetchRef = useRef(Date.now()) @@ -1138,8 +1170,35 @@ let PostFeed = ({ }, ) + const markPdsProfilesNearViewport = useCallback( + (item: FeedRow) => { + if (item.type === 'sliceItem') { + pdsViewabilityStore.markViewable( + collectPdsProfileDids(item.slice.items[item.indexInSlice].post), + ) + } else if (item.type === 'reposts') { + pdsViewabilityStore.markViewable( + item.items.flatMap(slice => + slice.items.flatMap(sliceItem => + Array.from(collectPdsProfileDids(sliceItem.post)), + ), + ), + ) + } else if (item.type === 'videoGridRow') { + pdsViewabilityStore.markViewable( + item.items.flatMap(sliceItem => + Array.from(collectPdsProfileDids(sliceItem.post)), + ), + ) + } + }, + [pdsViewabilityStore], + ) + const onItemSeen = useCallback( (item: FeedRow) => { + markPdsProfilesNearViewport(item) + feedFeedback.onItemSeen(item) // Events that should fire exactly once for every new post, regardless of @@ -1245,41 +1304,51 @@ let PostFeed = ({ } } }, - [feedFeedback, feed, liveNowConfig, getPostPosition, ax], + [ + feedFeedback, + feed, + liveNowConfig, + getPostPosition, + ax, + markPdsProfilesNearViewport, + ], ) return ( - - item.key} - renderItem={renderItem} - ListFooterComponent={FeedFooter} - ListHeaderComponent={ListHeaderComponent} - refreshing={isPTRing} - onRefresh={() => void onRefresh()} - headerOffset={headerOffset} - progressViewOffset={progressViewOffset} - contentContainerStyle={{ - minHeight: Dimensions.get('window').height * 1.5, - }} - onScrolledDownChange={handleScrolledDownChange} - onEndReached={() => void onEndReached()} - onEndReachedThreshold={2} // number of posts left to trigger load more - removeClippedSubviews={true} - extraData={extraData} - desktopFixedHeight={ - desktopFixedHeightOffset ? desktopFixedHeightOffset : true - } - initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} - windowSize={9} - maxToRenderPerBatch={IS_IOS ? 5 : 1} - updateCellsBatchingPeriod={40} - onItemSeen={onItemSeen} - /> - + + + item.key} + renderItem={renderItem} + ListFooterComponent={FeedFooter} + ListHeaderComponent={ListHeaderComponent} + refreshing={isPTRing} + onRefresh={() => void onRefresh()} + headerOffset={headerOffset} + progressViewOffset={progressViewOffset} + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} + onScrolledDownChange={handleScrolledDownChange} + onEndReached={() => void onEndReached()} + onEndReachedThreshold={2} // number of posts left to trigger load more + removeClippedSubviews={true} + extraData={extraData} + desktopFixedHeight={ + desktopFixedHeightOffset ? desktopFixedHeightOffset : true + } + initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} + windowSize={9} + maxToRenderPerBatch={IS_IOS ? 5 : 1} + updateCellsBatchingPeriod={40} + onItemNearViewport={markPdsProfilesNearViewport} + onItemSeen={onItemSeen} + /> + + ) } PostFeed = memo(PostFeed) diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 8cd2b44cd..4d2f79c12 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -1,5 +1,16 @@ -import {forwardRef, memo, useDeferredValue, useMemo} from 'react' -import {RefreshControl, type ViewToken} from 'react-native' +import { + forwardRef, + memo, + type ReactNode, + useDeferredValue, + useEffect, + useMemo, +} from 'react' +import { + type ListRenderItemInfo, + RefreshControl, + type ViewToken, +} from 'react-native' import { type FlatListPropsWithLayout, useAnimatedScrollHandler, @@ -36,6 +47,7 @@ export type ListProps = Omit< headerOffset?: number refreshing?: boolean onRefresh?: () => void + onItemNearViewport?: (item: ItemT) => void onItemSeen?: (item: ItemT) => void desktopFixedHeight?: number | boolean // Web only prop to contain the scroll to the container rather than the window @@ -54,6 +66,7 @@ let List = forwardRef( onScrollOffsetChange, refreshing, onRefresh, + onItemNearViewport, onItemSeen, headerOffset, style, @@ -132,9 +145,7 @@ let List = forwardRef( changed: Array }) => { for (const item of info.changed) { - if (item.isViewable) { - onItemSeen(item.item) - } + if (item.isViewable) onItemSeen(item.item) } }, { @@ -144,6 +155,19 @@ let List = forwardRef( ] }, [onItemSeen]) + const renderItem = useMemo(() => { + const render = props.renderItem + if (!onItemNearViewport || typeof render !== 'function') return render + + return (info: ListRenderItemInfo) => ( + + {render(info)} + + ) + }, [onItemNearViewport, props.renderItem]) + let refreshControl if (refreshing !== undefined || onRefresh !== undefined) { refreshControl = ( @@ -170,6 +194,7 @@ let List = forwardRef( onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={viewabilityConfig} {...props} + renderItem={renderItem} automaticallyAdjustsScrollIndicatorInsets={ automaticallyAdjustsScrollIndicatorInsets } @@ -195,6 +220,22 @@ List.displayName = 'List' List = memo(List) export {List} +function ItemNearViewport({ + item, + onItemNearViewport, + children, +}: { + item: ItemT + onItemNearViewport: (item: ItemT) => void + children: ReactNode +}) { + useEffect(() => { + onItemNearViewport(item) + }, [item, onItemNearViewport]) + + return children +} + // We only want to use this context value on iOS because the `scrollsToTop` prop is iOS-only // removing it saves us a re-render on Android const useAllowScrollToTop = IS_IOS ? useAllowScrollToTopIOS : () => undefined diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 9d8c11e2f..87c8abe70 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -54,6 +54,7 @@ export type ListProps = Omit< headerOffset?: number refreshing?: boolean onRefresh?: () => void + onItemNearViewport?: (item: ItemT) => void onItemSeen?: (item: ItemT) => void desktopFixedHeight?: number | boolean // Web only prop to contain the scroll to the container rather than the window @@ -69,6 +70,9 @@ const ON_ITEM_SEEN_WAIT_DURATION = 0.5e3 // when we consider post to be "seen" const ON_ITEM_SEEN_INTERSECTION_OPTS = { rootMargin: '-200px 0px -200px 0px', } // post must be 200px visible to be "seen" +const ON_ITEM_NEAR_VIEWPORT_INTERSECTION_OPTS = { + rootMargin: '800px 0px 800px 0px', +} function ListImpl( { @@ -90,6 +94,7 @@ function ListImpl( onScrolledDownChange, onScrollOffsetChange, onContentSizeChange, + onItemNearViewport, onItemSeen, renderItem, extraData, @@ -441,6 +446,7 @@ function ListImpl( index={index} renderItem={renderItem} extraData={extraData} + onItemNearViewport={onItemNearViewport} onItemSeen={onItemSeen} registerRowNode={registerRowNode} /> @@ -520,6 +526,7 @@ let Row = function RowImpl({ index, renderItem, extraData: _unused, + onItemNearViewport, onItemSeen, registerRowNode, }: { @@ -530,6 +537,7 @@ let Row = function RowImpl({ | undefined | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown + onItemNearViewport: ((item: ItemT) => void) | undefined onItemSeen: ((item: ItemT) => void) | undefined registerRowNode: (index: number, node: HTMLElement | null) => void }): React.ReactNode { @@ -538,6 +546,26 @@ let Row = function RowImpl({ undefined, ) + const handleNearViewportIntersection = useNonReactiveCallback( + (entries: IntersectionObserverEntry[]) => { + if (entries.some(entry => entry.isIntersecting)) { + onItemNearViewport?.(item) + } + }, + ) + + useEffect(() => { + if (!onItemNearViewport) return + + const observer = new IntersectionObserver( + handleNearViewportIntersection, + ON_ITEM_NEAR_VIEWPORT_INTERSECTION_OPTS, + ) + const row: Element | null = rowRef.current + if (row) observer.observe(row) + return () => observer.disconnect() + }, [handleNearViewportIntersection, onItemNearViewport]) + const handleIntersection = useNonReactiveCallback( (entries: IntersectionObserverEntry[]) => { batchedUpdates(() => { @@ -613,6 +641,7 @@ Row = memo(Row) as (props: { | undefined | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown + onItemNearViewport: ((item: ItemT) => void) | undefined onItemSeen: ((item: ItemT) => void) | undefined registerRowNode: (index: number, node: HTMLElement | null) => void }) => React.ReactNode