diff options
Diffstat (limited to 'src/state/queries/post-feed.ts')
-rw-r--r-- | src/state/queries/post-feed.ts | 303 |
1 files changed, 303 insertions, 0 deletions
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts new file mode 100644 index 000000000..7589aa346 --- /dev/null +++ b/src/state/queries/post-feed.ts @@ -0,0 +1,303 @@ +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<FeedPage>, + 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<FeedPageUnselected, RQPageParam>) => { + 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<AppBskyFeedDefs.PostView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<FeedPageUnselected> + >({ + 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) + } +} |