import {useCallback} from 'react' import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api' import { useInfiniteQuery, InfiniteData, QueryKey, QueryClient, useQueryClient, } from '@tanstack/react-query' import {useFeedTuners} from '../preferences/feed-tuners' import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' import {FollowingFeedAPI} from 'lib/api/feed/following' import {AuthorFeedAPI} from 'lib/api/feed/author' import {LikesFeedAPI} from 'lib/api/feed/likes' import {CustomFeedAPI} from 'lib/api/feed/custom' import {ListFeedAPI} from 'lib/api/feed/list' import {MergeFeedAPI} from 'lib/api/feed/merge' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri' import {getAgent} from '#/state/session' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' import {getModerationOpts} from '#/state/queries/preferences/moderation' import {KnownError} from '#/view/com/posts/FeedErrorMessage' type ActorDid = string type AuthorFilter = | 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' type FeedUri = string type ListUri = string export type FeedDescriptor = | 'home' | 'following' | `author|${ActorDid}|${AuthorFilter}` | `feedgen|${FeedUri}` | `likes|${ActorDid}` | `list|${ListUri}` export interface FeedParams { disableTuner?: boolean mergeFeedEnabled?: boolean mergeFeedSources?: string[] } type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) { return ['post-feed', feedDesc, params || {}] } export interface FeedPostSliceItem { _reactKey: string uri: string post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource } export interface FeedPostSlice { _reactKey: string rootUri: string isThread: boolean items: FeedPostSliceItem[] } export interface FeedPageUnselected { api: FeedAPI cursor: string | undefined feed: AppBskyFeedDefs.FeedViewPost[] } export interface FeedPage { api: FeedAPI tuner: FeedTuner | NoopFeedTuner cursor: string | undefined slices: FeedPostSlice[] } export function usePostFeedQuery( feedDesc: FeedDescriptor, params?: FeedParams, opts?: {enabled?: boolean}, ) { const queryClient = useQueryClient() const feedTuners = useFeedTuners(feedDesc) const enabled = opts?.enabled !== false return useInfiniteQuery< FeedPageUnselected, Error, InfiniteData, QueryKey, RQPageParam >({ enabled, staleTime: STALE.INFINITY, queryKey: RQKEY(feedDesc, params), async queryFn({pageParam}: {pageParam: RQPageParam}) { logger.debug('usePostFeedQuery', {feedDesc, pageParam}) const {api, cursor} = pageParam ? pageParam : { api: createApi(feedDesc, params || {}, feedTuners), cursor: undefined, } const res = await api.fetch({cursor, limit: 30}) precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution /* * If this is a public view, we need to check if posts fail moderation. * If all fail, we throw an error. If only some fail, we continue and let * moderations happen later, which results in some posts being shown and * some not. */ if (!getAgent().session) { assertSomePostsPassModeration(res.feed) } return { api, cursor: res.cursor, feed: res.feed, } }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor ? { api: lastPage.api, cursor: lastPage.cursor, } : undefined, select: useCallback( (data: InfiniteData) => { const tuner = params?.disableTuner ? new NoopFeedTuner() : new FeedTuner(feedTuners) return { pageParams: data.pageParams, pages: data.pages.map(page => ({ api: page.api, tuner, cursor: page.cursor, slices: tuner.tune(page.feed).map(slice => ({ _reactKey: slice._reactKey, rootUri: slice.rootItem.post.uri, isThread: slice.items.length > 1 && slice.items.every( item => item.post.author.did === slice.items[0].post.author.did, ), items: slice.items .map((item, i) => { if ( AppBskyFeedPost.isRecord(item.post.record) && AppBskyFeedPost.validateRecord(item.post.record).success ) { return { _reactKey: `${slice._reactKey}-${i}`, uri: item.post.uri, post: item.post, record: item.post.record, reason: i === 0 && slice.source ? slice.source : item.reason, } } return undefined }) .filter(Boolean) as FeedPostSliceItem[], })), })), } }, [feedTuners, params?.disableTuner], ), }) } export async function pollLatest(page: FeedPage | undefined) { if (!page) { return false } logger.debug('usePostFeedQuery: pollLatest') const post = await page.api.peekLatest() if (post) { const slices = page.tuner.tune([post], { dryRun: true, maintainOrder: true, }) if (slices[0]) { return true } } return false } function createApi( feedDesc: FeedDescriptor, params: FeedParams, feedTuners: FeedTunerFn[], ) { if (feedDesc === 'home') { return new MergeFeedAPI(params, feedTuners) } else if (feedDesc === 'following') { return new FollowingFeedAPI() } else if (feedDesc.startsWith('author')) { const [_, actor, filter] = feedDesc.split('|') return new AuthorFeedAPI({actor, filter}) } else if (feedDesc.startsWith('likes')) { const [_, actor] = feedDesc.split('|') return new LikesFeedAPI({actor}) } else if (feedDesc.startsWith('feedgen')) { const [_, feed] = feedDesc.split('|') return new CustomFeedAPI({feed}) } else if (feedDesc.startsWith('list')) { const [_, list] = feedDesc.split('|') return new ListFeedAPI({list}) } else { // shouldnt happen return new FollowingFeedAPI() } } /** * This helper is used by the post-thread placeholder function to * find a post in the query-data cache */ export function findPostInQueryData( queryClient: QueryClient, uri: string, ): AppBskyFeedDefs.PostView | undefined { const generator = findAllPostsInQueryData(queryClient, uri) const result = generator.next() if (result.done) { return undefined } else { return result.value } } export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, ): Generator { const queryDatas = queryClient.getQueriesData< InfiniteData >({ queryKey: ['post-feed'], }) for (const [_queryKey, queryData] of queryDatas) { if (!queryData?.pages) { continue } for (const page of queryData?.pages) { for (const item of page.feed) { if (item.post.uri === uri) { yield item.post } if ( AppBskyFeedDefs.isPostView(item.reply?.parent) && item.reply?.parent?.uri === uri ) { yield item.reply.parent } if ( AppBskyFeedDefs.isPostView(item.reply?.root) && item.reply?.root?.uri === uri ) { yield item.reply.root } } } } } function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { // assume false let somePostsPassModeration = false for (const item of feed) { const moderationOpts = getModerationOpts({ userDid: '', preferences: DEFAULT_LOGGED_OUT_PREFERENCES, }) const moderation = moderatePost(item.post, moderationOpts) if (!moderation.content.filter) { // we have a sfw post somePostsPassModeration = true } } if (!somePostsPassModeration) { throw new Error(KnownError.FeedNSFPublic) } }