mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at rm-proxy 621 lines 19 kB view raw
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}