Bluesky app fork with some witchin' additions 馃挮
at linkat-integration 650 lines 21 kB view raw
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}