bsky-app/src/state/queries/post-feed.ts
2023-11-30 13:53:34 -08:00

303 lines
8.4 KiB
TypeScript

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)
}
}