[Explore] Add shadow cache (#8119)

* enable shadowcache for feed previews

* extract keyextractor

* dedupe feeds
This commit is contained in:
Samuel Newman
2025-04-04 17:15:40 +03:00
committed by GitHub
parent a15658cb66
commit a0c644acfe
5 changed files with 194 additions and 41 deletions
+9 -5
View File
@@ -15,6 +15,10 @@ import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {
type FeedPreviewItem,
useFeedPreviews,
} from '#/state/queries/explore-feed-previews'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
@@ -47,10 +51,6 @@ import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import * as ModuleHeader from './components/ModuleHeader'
import {
type FeedPreviewItem,
useFeedPreviews,
} from './modules/ExploreFeedPreviews'
import {
SuggestedAccountsTabBar,
SuggestedProfileCard,
@@ -900,7 +900,7 @@ export function Explore({
<List
data={items}
renderItem={renderItem}
keyExtractor={item => item.key}
keyExtractor={keyExtractor}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
keyboardShouldPersistTaps="handled"
@@ -914,6 +914,10 @@ export function Explore({
)
}
function keyExtractor(item: FeedPreviewItem) {
return item.key
}
const viewabilityConfig: ViewabilityConfig = {
itemVisiblePercentThreshold: 100,
}
@@ -1,264 +0,0 @@
import {useMemo} from 'react'
import {type AppBskyFeedDefs, moderatePost} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInfiniteQuery} from '@tanstack/react-query'
import {CustomFeedAPI} from '#/lib/api/feed/custom'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner} from '#/lib/api/feed-manip'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
type FeedPostSlice,
type FeedPostSliceItem,
} from '#/state/queries/post-feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'feed-previews'
const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds]
const LIMIT = 8 // sliced to 6, overfetch to account for moderation
export type FeedPreviewItem =
| {
type: 'topBorder'
key: string
}
| {
type: 'preview:loading'
key: string
}
| {
type: 'preview:error'
key: string
message: string
error: string
}
| {
type: 'preview:loadMoreError'
key: string
}
| {
type: 'preview:empty'
key: string
}
| {
type: 'preview:header'
key: string
feed: AppBskyFeedDefs.GeneratorView
}
| {
type: 'preview:footer'
key: string
}
// copied from PostFeed.tsx
| {
type: 'preview:sliceItem'
key: string
slice: FeedPostSlice
indexInSlice: number
showReplyTo: boolean
hideTopBorder: boolean
}
| {
type: 'preview:sliceViewFullThread'
key: string
uri: string
}
export function useFeedPreviews(feeds: AppBskyFeedDefs.GeneratorView[]) {
const uris = feeds.map(feed => feed.uri)
const {_} = useLingui()
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
const userInterests = aggregateUserInterests(preferences)
const moderationOpts = useModerationOpts()
const enabled = feeds.length > 0
const query = useInfiniteQuery({
enabled,
queryKey: RQKEY(uris),
queryFn: async ({pageParam}) => {
const feed = feeds[pageParam]
const api = new CustomFeedAPI({
agent,
feedParams: {feed: feed.uri},
userInterests,
})
const data = await api.fetch({cursor: undefined, limit: LIMIT})
return {
feed,
posts: data.feed,
}
},
initialPageParam: 0,
getNextPageParam: (_p, _a, count) =>
count < feeds.length ? count + 1 : undefined,
})
const {data, isFetched, isError, isPending, error} = query
return {
query,
data: useMemo<FeedPreviewItem[]>(() => {
const items: FeedPreviewItem[] = []
if (!enabled) return items
const isEmpty =
!isPending && !data?.pages?.some(page => page.posts.length)
if (isFetched) {
if (isError && isEmpty) {
items.push({
type: 'preview:error',
key: 'error',
message: _(msg`An error occurred while fetching the feed.`),
error: cleanError(error),
})
} else if (isEmpty) {
items.push({
type: 'preview:empty',
key: 'empty',
})
} else if (data) {
for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex++) {
const page = data.pages[pageIndex]
// default feed tuner - we just want it to slice up the feed
const tuner = new FeedTuner([])
const slices: FeedPreviewItem[] = []
let rowIndex = 0
for (const item of tuner.tune(page.posts)) {
if (item.isFallbackMarker) continue
const moderations = item.items.map(item =>
moderatePost(item.post, moderationOpts!),
)
// apply moderation filters
item.items = item.items.filter((_, i) => {
return !moderations[i]?.ui('contentList').filter
})
const slice = {
_reactKey: item._reactKey,
_isFeedPostSlice: true,
isFallbackMarker: false,
isIncompleteThread: item.isIncompleteThread,
feedContext: item.feedContext,
reason: item.reason,
feedPostUri: item.feedPostUri,
items: item.items.slice(0, 6).map((subItem, i) => {
const feedPostSliceItem: FeedPostSliceItem = {
_reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`,
uri: subItem.post.uri,
post: subItem.post,
record: subItem.record,
moderation: moderations[i],
parentAuthor: subItem.parentAuthor,
isParentBlocked: subItem.isParentBlocked,
isParentNotFound: subItem.isParentNotFound,
}
return feedPostSliceItem
}),
}
if (slice.isIncompleteThread && slice.items.length >= 3) {
const beforeLast = slice.items.length - 2
const last = slice.items.length - 1
slices.push({
type: 'preview:sliceItem',
key: slice.items[0]._reactKey,
slice: slice,
indexInSlice: 0,
showReplyTo: false,
hideTopBorder: rowIndex === 0,
})
slices.push({
type: 'preview:sliceViewFullThread',
key: slice._reactKey + '-viewFullThread',
uri: slice.items[0].uri,
})
slices.push({
type: 'preview:sliceItem',
key: slice.items[beforeLast]._reactKey,
slice: slice,
indexInSlice: beforeLast,
showReplyTo:
slice.items[beforeLast].parentAuthor?.did !==
slice.items[beforeLast].post.author.did,
hideTopBorder: false,
})
slices.push({
type: 'preview:sliceItem',
key: slice.items[last]._reactKey,
slice: slice,
indexInSlice: last,
showReplyTo: false,
hideTopBorder: false,
})
} else {
for (let i = 0; i < slice.items.length; i++) {
slices.push({
type: 'preview:sliceItem',
key: slice.items[i]._reactKey,
slice: slice,
indexInSlice: i,
showReplyTo: i === 0,
hideTopBorder: i === 0 && rowIndex === 0,
})
}
}
rowIndex++
}
if (slices.length > 0) {
if (pageIndex > 0) {
items.push({
type: 'topBorder',
key: `topBorder-${page.feed.uri}`,
})
}
items.push(
{
type: 'preview:footer',
key: `footer-${page.feed.uri}`,
},
{
type: 'preview:header',
key: `header-${page.feed.uri}`,
feed: page.feed,
},
...slices,
)
}
}
} else if (isError && !isEmpty) {
items.push({
type: 'preview:loadMoreError',
key: 'loadMoreError',
})
}
} else {
items.push({
type: 'preview:loading',
key: 'loading',
})
}
return items
}, [
enabled,
data,
isFetched,
isError,
isPending,
moderationOpts,
_,
error,
]),
}
}