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