forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React, {useCallback, useEffect, useRef} from 'react'
2import {AppState} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 AppBskyFeedDefs,
6 type AppBskyFeedPost,
7 AtUri,
8 type BskyAgent,
9 moderatePost,
10 type ModerationDecision,
11 type ModerationPrefs,
12} from '@atproto/api'
13import {
14 type InfiniteData,
15 type QueryClient,
16 type QueryKey,
17 useInfiniteQuery,
18} from '@tanstack/react-query'
19
20import {AuthorFeedAPI} from '#/lib/api/feed/author'
21import {CustomFeedAPI} from '#/lib/api/feed/custom'
22import {DemoFeedAPI} from '#/lib/api/feed/demo'
23import {FollowingFeedAPI} from '#/lib/api/feed/following'
24import {HomeFeedAPI} from '#/lib/api/feed/home'
25import {LikesFeedAPI} from '#/lib/api/feed/likes'
26import {ListFeedAPI} from '#/lib/api/feed/list'
27import {MergeFeedAPI} from '#/lib/api/feed/merge'
28import {PostListFeedAPI} from '#/lib/api/feed/posts'
29import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
30import {aggregateUserInterests} from '#/lib/api/feed/utils'
31import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
32import {DISCOVER_FEED_URI} from '#/lib/constants'
33import {logger} from '#/logger'
34import {STALE} from '#/state/queries'
35import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
36import {useAgent} from '#/state/session'
37import * as userActionHistory from '#/state/userActionHistory'
38import {KnownError} from '#/view/com/posts/PostFeedErrorMessage'
39import {useFeedTuners} from '../preferences/feed-tuners'
40import {useModerationOpts} from '../preferences/moderation-opts'
41import {useNoDiscoverFallback} from '../preferences/no-discover-fallback'
42import {usePreferencesQuery} from './preferences'
43import {
44 didOrHandleUriMatches,
45 embedViewRecordToPostView,
46 getEmbeddedPost,
47} from './util'
48
49type ActorDid = string
50export type AuthorFilter =
51 | 'posts_with_replies'
52 | 'posts_no_replies'
53 | 'posts_and_author_threads'
54 | 'posts_with_media'
55 | 'posts_with_video'
56type FeedUri = string
57type ListUri = string
58type PostsUriList = string
59
60export type FeedDescriptor =
61 | 'following'
62 | `author|${ActorDid}|${AuthorFilter}`
63 | `feedgen|${FeedUri}`
64 | `likes|${ActorDid}`
65 | `list|${ListUri}`
66 | `posts|${PostsUriList}`
67 | 'demo'
68export interface FeedParams {
69 mergeFeedEnabled?: boolean
70 mergeFeedSources?: string[]
71 feedCacheKey?: 'discover' | 'explore' | undefined
72}
73
74type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
75
76export const RQKEY_ROOT = 'post-feed'
77export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
78 return [RQKEY_ROOT, feedDesc, params || {}]
79}
80
81export interface FeedPostSliceItem {
82 _reactKey: string
83 uri: string
84 post: AppBskyFeedDefs.PostView
85 record: AppBskyFeedPost.Record
86 moderation: ModerationDecision
87 parentAuthor?: AppBskyActorDefs.ProfileViewBasic
88 isParentBlocked?: boolean
89 isParentNotFound?: boolean
90}
91
92export interface FeedPostSlice {
93 _isFeedPostSlice: boolean
94 _reactKey: string
95 items: FeedPostSliceItem[]
96 isIncompleteThread: boolean
97 isFallbackMarker: boolean
98 feedContext: string | undefined
99 reqId: string | undefined
100 feedPostUri: string
101 reason?:
102 | AppBskyFeedDefs.ReasonRepost
103 | AppBskyFeedDefs.ReasonPin
104 | ReasonFeedSource
105 | {[k: string]: unknown; $type: string}
106}
107
108export interface FeedPageUnselected {
109 api: FeedAPI
110 cursor: string | undefined
111 feed: AppBskyFeedDefs.FeedViewPost[]
112 fetchedAt: number
113}
114
115export interface FeedPage {
116 api: FeedAPI
117 tuner: FeedTuner
118 cursor: string | undefined
119 slices: FeedPostSlice[]
120 fetchedAt: number
121}
122
123/**
124 * The minimum number of posts we want in a single "page" of results. Since we
125 * filter out unwanted content, we may fetch more than this number to ensure
126 * that we get _at least_ this number.
127 */
128const MIN_POSTS = 30
129
130export function usePostFeedQuery(
131 feedDesc: FeedDescriptor,
132 params?: FeedParams,
133 opts?: {enabled?: boolean; ignoreFilterFor?: string},
134) {
135 const feedTuners = useFeedTuners(feedDesc)
136 const moderationOpts = useModerationOpts()
137 const {data: preferences} = usePreferencesQuery()
138 /**
139 * Load bearing: we need to await AA state or risk FOUC. This marginally
140 * delays feeds, but AA state is fetched immediately on load and is then
141 * available for the remainder of the session, so this delay only affects cold
142 * loads. -esb
143 */
144 const enabled =
145 opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences)
146 const userInterests = aggregateUserInterests(preferences)
147 const followingPinnedIndex =
148 preferences?.savedFeeds?.findIndex(
149 f => f.pinned && f.value === 'following',
150 ) ?? -1
151 const noDiscoverFallback = useNoDiscoverFallback()
152 const enableFollowingToDiscoverFallback =
153 followingPinnedIndex === 0 && !noDiscoverFallback
154 const agent = useAgent()
155 const lastRun = useRef<{
156 data: InfiniteData<FeedPageUnselected>
157 args: typeof selectArgs
158 result: InfiniteData<FeedPage>
159 } | null>(null)
160 const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
161
162 /**
163 * The number of posts to fetch in a single request. Because we filter
164 * unwanted content, we may over-fetch here to try and fill pages by
165 * `MIN_POSTS`. But if you're doing this, ask @why if it's ok first.
166 */
167 const fetchLimit = MIN_POSTS
168
169 // Make sure this doesn't invalidate unless really needed.
170 const selectArgs = React.useMemo(
171 () => ({
172 feedTuners,
173 moderationOpts,
174 ignoreFilterFor: opts?.ignoreFilterFor,
175 isDiscover,
176 }),
177 [feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover],
178 )
179
180 const query = useInfiniteQuery<
181 FeedPageUnselected,
182 Error,
183 InfiniteData<FeedPage>,
184 QueryKey,
185 RQPageParam
186 >({
187 enabled,
188 staleTime: STALE.INFINITY,
189 queryKey: RQKEY(feedDesc, params),
190 async queryFn({pageParam}: {pageParam: RQPageParam}) {
191 logger.debug('usePostFeedQuery', {feedDesc, cursor: pageParam?.cursor})
192 const {api, cursor} = pageParam
193 ? pageParam
194 : {
195 api: createApi({
196 feedDesc,
197 feedParams: params || {},
198 feedTuners,
199 agent,
200 // Not in the query key because they don't change:
201 userInterests,
202 // Not in the query key. Reacting to it switching isn't important:
203 enableFollowingToDiscoverFallback,
204 }),
205 cursor: undefined,
206 }
207
208 const res = await api.fetch({cursor, limit: fetchLimit})
209
210 /*
211 * If this is a public view, we need to check if posts fail moderation.
212 * If all fail, we throw an error. If only some fail, we continue and let
213 * moderations happen later, which results in some posts being shown and
214 * some not.
215 */
216 if (!agent.session) {
217 assertSomePostsPassModeration(
218 res.feed,
219 preferences?.moderationPrefs ||
220 DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
221 )
222 }
223
224 return {
225 api,
226 cursor: res.cursor,
227 feed: res.feed,
228 fetchedAt: Date.now(),
229 }
230 },
231 initialPageParam: undefined,
232 getNextPageParam: lastPage =>
233 lastPage.cursor
234 ? {
235 api: lastPage.api,
236 cursor: lastPage.cursor,
237 }
238 : undefined,
239 select: useCallback(
240 (data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
241 // If the selection depends on some data, that data should
242 // be included in the selectArgs object and read here.
243 const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} =
244 selectArgs
245
246 const tuner = new FeedTuner(feedTuners)
247
248 // Keep track of the last run and whether we can reuse
249 // some already selected pages from there.
250 let reusedPages = []
251 if (lastRun.current) {
252 const {
253 data: lastData,
254 args: lastArgs,
255 result: lastResult,
256 } = lastRun.current
257 let canReuse = true
258 for (let key in selectArgs) {
259 if (selectArgs.hasOwnProperty(key)) {
260 if ((selectArgs as any)[key] !== (lastArgs as any)[key]) {
261 // Can't do reuse anything if any input has changed.
262 canReuse = false
263 break
264 }
265 }
266 }
267 if (canReuse) {
268 for (let i = 0; i < data.pages.length; i++) {
269 if (data.pages[i] && lastData.pages[i] === data.pages[i]) {
270 reusedPages.push(lastResult.pages[i])
271 // Keep the tuner in sync so that the end result is deterministic.
272 tuner.tune(lastData.pages[i].feed)
273 continue
274 }
275 // Stop as soon as pages stop matching up.
276 break
277 }
278 }
279 }
280
281 const result = {
282 pageParams: data.pageParams,
283 pages: [
284 ...reusedPages,
285 ...data.pages.slice(reusedPages.length).map(page => ({
286 api: page.api,
287 tuner,
288 cursor: page.cursor,
289 fetchedAt: page.fetchedAt,
290 slices: tuner
291 .tune(page.feed)
292 .map(slice => {
293 const moderations = slice.items.map(item =>
294 moderatePost(item.post, moderationOpts!),
295 )
296
297 // apply moderation filter
298 for (let i = 0; i < slice.items.length; i++) {
299 const ignoreFilter =
300 slice.items[i].post.author.did === ignoreFilterFor
301 if (ignoreFilter) {
302 // remove mutes to avoid confused UIs
303 moderations[i].causes = moderations[i].causes.filter(
304 cause => cause.type !== 'muted',
305 )
306 }
307 if (
308 !ignoreFilter &&
309 moderations[i]?.ui('contentList').filter
310 ) {
311 return undefined
312 }
313 }
314
315 if (isDiscover) {
316 userActionHistory.seen(
317 slice.items.map(item => ({
318 feedContext: slice.feedContext,
319 reqId: slice.reqId,
320 likeCount: item.post.likeCount ?? 0,
321 repostCount: item.post.repostCount ?? 0,
322 replyCount: item.post.replyCount ?? 0,
323 isFollowedBy: Boolean(
324 item.post.author.viewer?.followedBy,
325 ),
326 uri: item.post.uri,
327 })),
328 )
329 }
330
331 const feedPostSlice: FeedPostSlice = {
332 _reactKey: slice._reactKey,
333 _isFeedPostSlice: true,
334 isIncompleteThread: slice.isIncompleteThread,
335 isFallbackMarker: slice.isFallbackMarker,
336 feedContext: slice.feedContext,
337 reqId: slice.reqId,
338 reason: slice.reason,
339 feedPostUri: slice.feedPostUri,
340 items: slice.items.map((item, i) => {
341 const feedPostSliceItem: FeedPostSliceItem = {
342 _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
343 uri: item.post.uri,
344 post: item.post,
345 record: item.record,
346 moderation: moderations[i],
347 parentAuthor: item.parentAuthor,
348 isParentBlocked: item.isParentBlocked,
349 isParentNotFound: item.isParentNotFound,
350 }
351 return feedPostSliceItem
352 }),
353 }
354 return feedPostSlice
355 })
356 .filter(n => !!n),
357 })),
358 ],
359 }
360 // Save for memoization.
361 lastRun.current = {data, result, args: selectArgs}
362 return result
363 },
364 [selectArgs /* Don't change. Everything needs to go into selectArgs. */],
365 ),
366 })
367
368 // The server may end up returning an empty page, a page with too few items,
369 // or a page with items that end up getting filtered out. When we fetch pages,
370 // we'll keep track of how many items we actually hope to see. If the server
371 // doesn't return enough items, we're going to continue asking for more items.
372 const lastItemCount = useRef(0)
373 const wantedItemCount = useRef(0)
374 const autoPaginationAttemptCount = useRef(0)
375 useEffect(() => {
376 const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
377 query
378 // Count the items that we already have.
379 let itemCount = 0
380 for (const page of data?.pages || []) {
381 for (const slice of page.slices) {
382 itemCount += slice.items.length
383 }
384 }
385
386 // If items got truncated, reset the state we're tracking below.
387 if (itemCount !== lastItemCount.current) {
388 if (itemCount < lastItemCount.current) {
389 wantedItemCount.current = itemCount
390 }
391 lastItemCount.current = itemCount
392 }
393
394 // Now track how many items we really want, and fetch more if needed.
395 if (isLoading || isRefetching) {
396 // During the initial fetch, we want to get an entire page's worth of items.
397 wantedItemCount.current = MIN_POSTS
398 } else if (isFetchingNextPage) {
399 if (itemCount > wantedItemCount.current) {
400 // We have more items than wantedItemCount, so wantedItemCount must be out of date.
401 // Some other code must have called fetchNextPage(), for example, from onEndReached.
402 // Adjust the wantedItemCount to reflect that we want one more full page of items.
403 wantedItemCount.current = itemCount + MIN_POSTS
404 }
405 } else if (hasNextPage) {
406 // At this point we're not fetching anymore, so it's time to make a decision.
407 // If we didn't receive enough items from the server, paginate again until we do.
408 if (itemCount < wantedItemCount.current) {
409 autoPaginationAttemptCount.current++
410 if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
411 query.fetchNextPage()
412 }
413 } else {
414 autoPaginationAttemptCount.current = 0
415 }
416 }
417 }, [query])
418
419 return query
420}
421
422export async function pollLatest(page: FeedPage | undefined) {
423 if (!page) {
424 return false
425 }
426 if (AppState.currentState !== 'active') {
427 return
428 }
429
430 logger.debug('usePostFeedQuery: pollLatest')
431 const post = await page.api.peekLatest()
432 if (post) {
433 const slices = page.tuner.tune([post], {
434 dryRun: true,
435 })
436 if (slices[0]) {
437 return true
438 }
439 }
440
441 return false
442}
443
444function createApi({
445 feedDesc,
446 feedParams,
447 feedTuners,
448 userInterests,
449 agent,
450 enableFollowingToDiscoverFallback,
451}: {
452 feedDesc: FeedDescriptor
453 feedParams: FeedParams
454 feedTuners: FeedTunerFn[]
455 userInterests?: string
456 agent: BskyAgent
457 enableFollowingToDiscoverFallback: boolean
458}) {
459 if (feedDesc === 'following') {
460 if (feedParams.mergeFeedEnabled) {
461 return new MergeFeedAPI({
462 agent,
463 feedParams,
464 feedTuners,
465 userInterests,
466 })
467 } else {
468 if (enableFollowingToDiscoverFallback) {
469 return new HomeFeedAPI({agent, userInterests})
470 } else {
471 return new FollowingFeedAPI({agent})
472 }
473 }
474 } else if (feedDesc.startsWith('author')) {
475 const [__, actor, filter] = feedDesc.split('|')
476 return new AuthorFeedAPI({agent, feedParams: {actor, filter}})
477 } else if (feedDesc.startsWith('likes')) {
478 const [__, actor] = feedDesc.split('|')
479 return new LikesFeedAPI({agent, feedParams: {actor}})
480 } else if (feedDesc.startsWith('feedgen')) {
481 const [__, feed] = feedDesc.split('|')
482 return new CustomFeedAPI({
483 agent,
484 feedParams: {feed},
485 userInterests,
486 })
487 } else if (feedDesc.startsWith('list')) {
488 const [__, list] = feedDesc.split('|')
489 return new ListFeedAPI({agent, feedParams: {list}})
490 } else if (feedDesc.startsWith('posts')) {
491 const [__, uriList] = feedDesc.split('|')
492 return new PostListFeedAPI({agent, feedParams: {uris: uriList.split(',')}})
493 } else if (feedDesc === 'demo') {
494 return new DemoFeedAPI({agent})
495 } else {
496 // shouldnt happen
497 return new FollowingFeedAPI({agent})
498 }
499}
500
501export function* findAllPostsInQueryData(
502 queryClient: QueryClient,
503 uri: string,
504): Generator<AppBskyFeedDefs.PostView, undefined> {
505 const atUri = new AtUri(uri)
506
507 const queryDatas = queryClient.getQueriesData<
508 InfiniteData<FeedPageUnselected>
509 >({
510 queryKey: [RQKEY_ROOT],
511 })
512 for (const [_queryKey, queryData] of queryDatas) {
513 if (!queryData?.pages) {
514 continue
515 }
516 for (const page of queryData?.pages) {
517 for (const item of page.feed) {
518 if (didOrHandleUriMatches(atUri, item.post)) {
519 yield item.post
520 }
521
522 const quotedPost = getEmbeddedPost(item.post.embed)
523 if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
524 yield embedViewRecordToPostView(quotedPost)
525 }
526
527 if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
528 if (didOrHandleUriMatches(atUri, item.reply.parent)) {
529 yield item.reply.parent
530 }
531
532 const parentQuotedPost = getEmbeddedPost(item.reply.parent.embed)
533 if (
534 parentQuotedPost &&
535 didOrHandleUriMatches(atUri, parentQuotedPost)
536 ) {
537 yield embedViewRecordToPostView(parentQuotedPost)
538 }
539 }
540
541 if (AppBskyFeedDefs.isPostView(item.reply?.root)) {
542 if (didOrHandleUriMatches(atUri, item.reply.root)) {
543 yield item.reply.root
544 }
545
546 const rootQuotedPost = getEmbeddedPost(item.reply.root.embed)
547 if (rootQuotedPost && didOrHandleUriMatches(atUri, rootQuotedPost)) {
548 yield embedViewRecordToPostView(rootQuotedPost)
549 }
550 }
551 }
552 }
553 }
554}
555
556export function* findAllProfilesInQueryData(
557 queryClient: QueryClient,
558 did: string,
559): Generator<AppBskyActorDefs.ProfileViewBasic, undefined> {
560 const queryDatas = queryClient.getQueriesData<
561 InfiniteData<FeedPageUnselected>
562 >({
563 queryKey: [RQKEY_ROOT],
564 })
565 for (const [_queryKey, queryData] of queryDatas) {
566 if (!queryData?.pages) {
567 continue
568 }
569 for (const page of queryData?.pages) {
570 for (const item of page.feed) {
571 if (item.post.author.did === did) {
572 yield item.post.author
573 }
574 const quotedPost = getEmbeddedPost(item.post.embed)
575 if (quotedPost?.author.did === did) {
576 yield quotedPost.author
577 }
578 if (
579 AppBskyFeedDefs.isPostView(item.reply?.parent) &&
580 item.reply?.parent?.author.did === did
581 ) {
582 yield item.reply.parent.author
583 }
584 if (
585 AppBskyFeedDefs.isPostView(item.reply?.root) &&
586 item.reply?.root?.author.did === did
587 ) {
588 yield item.reply.root.author
589 }
590 }
591 }
592 }
593}
594
595function assertSomePostsPassModeration(
596 feed: AppBskyFeedDefs.FeedViewPost[],
597 moderationPrefs: ModerationPrefs,
598) {
599 // no posts in this feed
600 if (feed.length === 0) return true
601
602 // assume false
603 let somePostsPassModeration = false
604
605 for (const item of feed) {
606 const moderation = moderatePost(item.post, {
607 userDid: undefined,
608 prefs: moderationPrefs,
609 })
610
611 if (!moderation.ui('contentList').filter) {
612 // we have a sfw post
613 somePostsPassModeration = true
614 }
615 }
616
617 if (!somePostsPassModeration) {
618 throw new Error(KnownError.FeedSignedInOnly)
619 }
620}
621
622export function resetPostsFeedQueries(queryClient: QueryClient, timeout = 0) {
623 setTimeout(() => {
624 queryClient.resetQueries({
625 predicate: query => query.queryKey[0] === RQKEY_ROOT,
626 })
627 }, timeout)
628}
629
630export function resetProfilePostsQueries(
631 queryClient: QueryClient,
632 did: string,
633 timeout = 0,
634) {
635 setTimeout(() => {
636 queryClient.resetQueries({
637 predicate: query =>
638 !!(
639 query.queryKey[0] === RQKEY_ROOT &&
640 (query.queryKey[1] as string)?.includes(did)
641 ),
642 })
643 }, timeout)
644}
645
646export function isFeedPostSlice(v: any): v is FeedPostSlice {
647 return (
648 v && typeof v === 'object' && '_isFeedPostSlice' in v && v._isFeedPostSlice
649 )
650}