Bluesky app fork with some witchin' additions 馃挮
at main 687 lines 19 kB view raw
1import {useCallback, useEffect, useMemo, useRef} from 'react' 2import { 3 type AppBskyActorDefs, 4 type AppBskyFeedDefs, 5 type AppBskyGraphDefs, 6 type AppBskyUnspeccedGetPopularFeedGenerators, 7 AtUri, 8 moderateFeedGenerator, 9 RichText, 10} from '@atproto/api' 11import {t} from '@lingui/macro' 12import { 13 type InfiniteData, 14 keepPreviousData, 15 type QueryClient, 16 useInfiniteQuery, 17 useMutation, 18 useQuery, 19 useQueryClient, 20} from '@tanstack/react-query' 21 22import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants' 23import {sanitizeDisplayName} from '#/lib/strings/display-names' 24import {sanitizeHandle} from '#/lib/strings/handles' 25import { 26 PERSISTED_QUERY_GCTIME, 27 PERSISTED_QUERY_ROOT, 28 STALE, 29} from '#/state/queries' 30import {RQKEY as listQueryKey} from '#/state/queries/list' 31import {usePreferencesQuery} from '#/state/queries/preferences' 32import {useAgent, useSession} from '#/state/session' 33import {router} from '#/routes' 34import {useModerationOpts} from '../preferences/moderation-opts' 35import {type FeedDescriptor} from './post-feed' 36import {precacheResolvedUri} from './resolve-uri' 37 38export type FeedSourceFeedInfo = { 39 type: 'feed' 40 view?: AppBskyFeedDefs.GeneratorView 41 uri: string 42 feedDescriptor: FeedDescriptor 43 route: { 44 href: string 45 name: string 46 params: Record<string, string> 47 } 48 cid: string 49 avatar: string | undefined 50 displayName: string 51 description: RichText 52 creatorDid: string 53 creatorHandle: string 54 likeCount: number | undefined 55 acceptsInteractions?: boolean 56 likeUri: string | undefined 57 contentMode: AppBskyFeedDefs.GeneratorView['contentMode'] 58} 59 60export type FeedSourceListInfo = { 61 type: 'list' 62 view?: AppBskyGraphDefs.ListView 63 uri: string 64 feedDescriptor: FeedDescriptor 65 route: { 66 href: string 67 name: string 68 params: Record<string, string> 69 } 70 cid: string 71 avatar: string | undefined 72 displayName: string 73 description: RichText 74 creatorDid: string 75 creatorHandle: string 76 contentMode: undefined 77} 78 79export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo 80 81export function isFeedSourceFeedInfo( 82 feed: FeedSourceInfo, 83): feed is FeedSourceFeedInfo { 84 return feed.type === 'feed' 85} 86 87const feedSourceInfoQueryKeyRoot = 'getFeedSourceInfo' 88export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [ 89 feedSourceInfoQueryKeyRoot, 90 uri, 91] 92 93const feedSourceNSIDs = { 94 feed: 'app.bsky.feed.generator', 95 list: 'app.bsky.graph.list', 96} 97 98export function hydrateFeedGenerator( 99 view: AppBskyFeedDefs.GeneratorView, 100): FeedSourceInfo { 101 const urip = new AtUri(view.uri) 102 const collection = 103 urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists' 104 const href = `/profile/${urip.hostname}/${collection}/${urip.rkey}` 105 const route = router.matchPath(href) 106 107 return { 108 type: 'feed', 109 view, 110 uri: view.uri, 111 feedDescriptor: `feedgen|${view.uri}`, 112 cid: view.cid, 113 route: { 114 href, 115 name: route[0], 116 params: route[1], 117 }, 118 avatar: view.avatar, 119 displayName: view.displayName 120 ? sanitizeDisplayName(view.displayName) 121 : t`Feed by ${sanitizeHandle(view.creator.handle, '@')}`, 122 description: new RichText({ 123 text: view.description || '', 124 facets: (view.descriptionFacets || [])?.slice(), 125 }), 126 creatorDid: view.creator.did, 127 creatorHandle: view.creator.handle, 128 likeCount: view.likeCount, 129 acceptsInteractions: view.acceptsInteractions, 130 likeUri: view.viewer?.like, 131 contentMode: view.contentMode, 132 } 133} 134 135export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo { 136 const urip = new AtUri(view.uri) 137 const collection = 138 urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists' 139 const href = `/profile/${urip.hostname}/${collection}/${urip.rkey}` 140 const route = router.matchPath(href) 141 142 return { 143 type: 'list', 144 view, 145 uri: view.uri, 146 feedDescriptor: `list|${view.uri}`, 147 route: { 148 href, 149 name: route[0], 150 params: route[1], 151 }, 152 cid: view.cid, 153 avatar: view.avatar, 154 description: new RichText({ 155 text: view.description || '', 156 facets: (view.descriptionFacets || [])?.slice(), 157 }), 158 creatorDid: view.creator.did, 159 creatorHandle: view.creator.handle, 160 displayName: view.name 161 ? sanitizeDisplayName(view.name) 162 : t`User List by ${sanitizeHandle(view.creator.handle, '@')}`, 163 contentMode: undefined, 164 } 165} 166 167export function getFeedTypeFromUri(uri: string) { 168 const {pathname} = new AtUri(uri) 169 return pathname.includes(feedSourceNSIDs.feed) ? 'feed' : 'list' 170} 171 172export function getAvatarTypeFromUri(uri: string) { 173 return getFeedTypeFromUri(uri) === 'feed' ? 'algo' : 'list' 174} 175 176export function useFeedSourceInfoQuery({uri}: {uri: string}) { 177 const type = getFeedTypeFromUri(uri) 178 const agent = useAgent() 179 180 return useQuery({ 181 staleTime: STALE.INFINITY, 182 queryKey: feedSourceInfoQueryKey({uri}), 183 queryFn: async () => { 184 let view: FeedSourceInfo 185 186 if (type === 'feed') { 187 const res = await agent.app.bsky.feed.getFeedGenerator({feed: uri}) 188 view = hydrateFeedGenerator(res.data.view) 189 } else { 190 const res = await agent.app.bsky.graph.getList({ 191 list: uri, 192 limit: 1, 193 }) 194 view = hydrateList(res.data.list) 195 } 196 197 return view 198 }, 199 }) 200} 201 202// HACK 203// the protocol doesn't yet tell us which feeds are personalized 204// this list is used to filter out feed recommendations from logged out users 205// for the ones we know need it 206// -prf 207export const KNOWN_AUTHED_ONLY_FEEDS = [ 208 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app 209 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed 210 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed 211 'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow 212 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz 213 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky 214 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz 215 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why 216] 217 218type GetPopularFeedsOptions = {limit?: number; enabled?: boolean} 219 220export function createGetPopularFeedsQueryKey( 221 options?: GetPopularFeedsOptions, 222) { 223 return ['getPopularFeeds', options?.limit] 224} 225 226export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { 227 const {hasSession} = useSession() 228 const agent = useAgent() 229 const limit = options?.limit || 10 230 const {data: preferences} = usePreferencesQuery() 231 const queryClient = useQueryClient() 232 const moderationOpts = useModerationOpts() 233 234 // Make sure this doesn't invalidate unless really needed. 235 const selectArgs = useMemo( 236 () => ({ 237 hasSession, 238 savedFeeds: preferences?.savedFeeds || [], 239 moderationOpts, 240 }), 241 [hasSession, preferences?.savedFeeds, moderationOpts], 242 ) 243 const lastPageCountRef = useRef(0) 244 245 const query = useInfiniteQuery({ 246 enabled: Boolean(moderationOpts) && options?.enabled !== false, 247 queryKey: createGetPopularFeedsQueryKey(options), 248 queryFn: async ({pageParam}) => { 249 const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ 250 limit, 251 cursor: pageParam, 252 }) 253 254 // precache feeds 255 for (const feed of res.data.feeds) { 256 const hydratedFeed = hydrateFeedGenerator(feed) 257 precacheFeed(queryClient, hydratedFeed) 258 } 259 260 return res.data 261 }, 262 initialPageParam: undefined as string | undefined, 263 getNextPageParam: lastPage => lastPage.cursor, 264 select: useCallback( 265 ( 266 data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>, 267 ) => { 268 const { 269 savedFeeds, 270 hasSession: hasSessionInner, 271 moderationOpts, 272 } = selectArgs 273 return { 274 ...data, 275 pages: data.pages.map(page => { 276 return { 277 ...page, 278 feeds: page.feeds.filter(feed => { 279 if ( 280 !hasSessionInner && 281 KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri) 282 ) { 283 return false 284 } 285 const alreadySaved = Boolean( 286 savedFeeds?.find(f => { 287 return f.value === feed.uri 288 }), 289 ) 290 const decision = moderateFeedGenerator(feed, moderationOpts!) 291 return !alreadySaved && !decision.ui('contentList').filter 292 }), 293 } 294 }), 295 } 296 }, 297 [selectArgs /* Don't change. Everything needs to go into selectArgs. */], 298 ), 299 }) 300 301 useEffect(() => { 302 const {isFetching, hasNextPage, data} = query 303 if (isFetching || !hasNextPage) { 304 return 305 } 306 307 // avoid double-fires of fetchNextPage() 308 if ( 309 lastPageCountRef.current !== 0 && 310 lastPageCountRef.current === data?.pages?.length 311 ) { 312 return 313 } 314 315 // fetch next page if we haven't gotten a full page of content 316 let count = 0 317 for (const page of data?.pages || []) { 318 count += page.feeds.length 319 } 320 if (count < limit && (data?.pages.length || 0) < 6) { 321 query.fetchNextPage() 322 lastPageCountRef.current = data?.pages?.length || 0 323 } 324 }, [query, limit]) 325 326 return query 327} 328 329export function useSearchPopularFeedsMutation() { 330 const agent = useAgent() 331 const moderationOpts = useModerationOpts() 332 333 return useMutation({ 334 mutationFn: async (query: string) => { 335 const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ 336 limit: 10, 337 query: query, 338 }) 339 340 if (moderationOpts) { 341 return res.data.feeds.filter(feed => { 342 const decision = moderateFeedGenerator(feed, moderationOpts) 343 return !decision.ui('contentMedia').blur 344 }) 345 } 346 347 return res.data.feeds 348 }, 349 }) 350} 351 352const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch' 353export const createPopularFeedsSearchQueryKey = (query: string) => [ 354 popularFeedsSearchQueryKeyRoot, 355 query, 356] 357 358export function usePopularFeedsSearch({ 359 query, 360 enabled, 361}: { 362 query: string 363 enabled?: boolean 364}) { 365 const agent = useAgent() 366 const moderationOpts = useModerationOpts() 367 const enabledInner = enabled ?? Boolean(moderationOpts) 368 369 return useQuery({ 370 enabled: enabledInner, 371 queryKey: createPopularFeedsSearchQueryKey(query), 372 queryFn: async () => { 373 const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ 374 limit: 15, 375 query: query, 376 }) 377 378 return res.data.feeds 379 }, 380 placeholderData: keepPreviousData, 381 select(data) { 382 return data.filter(feed => { 383 const decision = moderateFeedGenerator(feed, moderationOpts!) 384 return !decision.ui('contentMedia').blur 385 }) 386 }, 387 }) 388} 389 390export type SavedFeedSourceInfo = FeedSourceInfo & { 391 savedFeed: AppBskyActorDefs.SavedFeed 392} 393 394const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = { 395 type: 'feed', 396 displayName: 'Discover', 397 uri: DISCOVER_FEED_URI, 398 feedDescriptor: `feedgen|${DISCOVER_FEED_URI}`, 399 route: { 400 href: '/', 401 name: 'Home', 402 params: {}, 403 }, 404 cid: '', 405 avatar: '', 406 description: new RichText({text: ''}), 407 creatorDid: '', 408 creatorHandle: '', 409 likeCount: 0, 410 likeUri: '', 411 // --- 412 savedFeed: { 413 id: 'pwi-discover', 414 ...DISCOVER_SAVED_FEED, 415 }, 416 contentMode: undefined, 417} 418 419const createPinnedFeedInfosQueryKeyRoot = ( 420 kind: 'pinned' | 'saved', 421 feedUris: string[], 422) => [PERSISTED_QUERY_ROOT, 'feed-info', kind, feedUris] 423 424export function usePinnedFeedsInfos() { 425 const {hasSession} = useSession() 426 const agent = useAgent() 427 const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() 428 const pinnedItems = preferences?.savedFeeds.filter(feed => feed.pinned) ?? [] 429 430 return useQuery({ 431 queryKey: createPinnedFeedInfosQueryKeyRoot( 432 'pinned', 433 pinnedItems.map(f => f.value), 434 ), 435 gcTime: PERSISTED_QUERY_GCTIME, 436 staleTime: STALE.INFINITY, 437 enabled: !isLoadingPrefs, 438 queryFn: async () => { 439 if (!hasSession) { 440 return [PWI_DISCOVER_FEED_STUB] 441 } 442 443 let resolved = new Map<string, FeedSourceInfo>() 444 445 // Get all feeds. We can do this in a batch. 446 const pinnedFeeds = pinnedItems.filter(feed => feed.type === 'feed') 447 let feedsPromise = Promise.resolve() 448 if (pinnedFeeds.length > 0) { 449 feedsPromise = agent.app.bsky.feed 450 .getFeedGenerators({ 451 feeds: pinnedFeeds.map(f => f.value), 452 }) 453 .then(res => { 454 for (let i = 0; i < res.data.feeds.length; i++) { 455 const feedView = res.data.feeds[i] 456 resolved.set(feedView.uri, hydrateFeedGenerator(feedView)) 457 } 458 }) 459 } 460 461 // Get all lists. This currently has to be done individually. 462 const pinnedLists = pinnedItems.filter(feed => feed.type === 'list') 463 const listsPromises = pinnedLists.map(list => 464 agent.app.bsky.graph 465 .getList({ 466 list: list.value, 467 limit: 1, 468 }) 469 .then(res => { 470 const listView = res.data.list 471 resolved.set(listView.uri, hydrateList(listView)) 472 }), 473 ) 474 475 await feedsPromise // Fail the whole query if it fails. 476 await Promise.allSettled(listsPromises) // Ignore individual failing ones. 477 478 // order the feeds/lists in the order they were pinned 479 const result: SavedFeedSourceInfo[] = [] 480 for (let pinnedItem of pinnedItems) { 481 const feedInfo = resolved.get(pinnedItem.value) 482 if (feedInfo) { 483 result.push({ 484 ...feedInfo, 485 savedFeed: pinnedItem, 486 }) 487 } else if (pinnedItem.type === 'timeline') { 488 result.push({ 489 type: 'feed', 490 displayName: 'Following', 491 uri: pinnedItem.value, 492 feedDescriptor: 'following', 493 route: { 494 href: '/', 495 name: 'Home', 496 params: {}, 497 }, 498 cid: '', 499 avatar: '', 500 description: new RichText({text: ''}), 501 creatorDid: '', 502 creatorHandle: '', 503 likeCount: 0, 504 likeUri: '', 505 savedFeed: pinnedItem, 506 contentMode: undefined, 507 }) 508 } 509 } 510 return result 511 }, 512 }) 513} 514 515export type SavedFeedItem = 516 | { 517 type: 'feed' 518 config: AppBskyActorDefs.SavedFeed 519 view: AppBskyFeedDefs.GeneratorView 520 } 521 | { 522 type: 'list' 523 config: AppBskyActorDefs.SavedFeed 524 view: AppBskyGraphDefs.ListView 525 } 526 | { 527 type: 'timeline' 528 config: AppBskyActorDefs.SavedFeed 529 view: undefined 530 } 531 532export function useSavedFeeds() { 533 const agent = useAgent() 534 const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() 535 const savedItems = preferences?.savedFeeds ?? [] 536 const queryClient = useQueryClient() 537 538 return useQuery({ 539 queryKey: createPinnedFeedInfosQueryKeyRoot( 540 'saved', 541 savedItems.map(f => f.value), 542 ), 543 gcTime: PERSISTED_QUERY_GCTIME, 544 staleTime: STALE.INFINITY, 545 enabled: !isLoadingPrefs, 546 placeholderData: previousData => { 547 return ( 548 previousData || { 549 // The likely count before we try to resolve them. 550 count: savedItems.length, 551 feeds: [], 552 } 553 ) 554 }, 555 queryFn: async () => { 556 const resolvedFeeds = new Map<string, AppBskyFeedDefs.GeneratorView>() 557 const resolvedLists = new Map<string, AppBskyGraphDefs.ListView>() 558 559 const savedFeeds = savedItems.filter(feed => feed.type === 'feed') 560 const savedLists = savedItems.filter(feed => feed.type === 'list') 561 562 let feedsPromise = Promise.resolve() 563 if (savedFeeds.length > 0) { 564 feedsPromise = agent.app.bsky.feed 565 .getFeedGenerators({ 566 feeds: savedFeeds.map(f => f.value), 567 }) 568 .then(res => { 569 res.data.feeds.forEach(f => { 570 resolvedFeeds.set(f.uri, f) 571 }) 572 }) 573 } 574 575 const listsPromises = savedLists.map(list => 576 agent.app.bsky.graph 577 .getList({ 578 list: list.value, 579 limit: 1, 580 }) 581 .then(res => { 582 const listView = res.data.list 583 resolvedLists.set(listView.uri, listView) 584 }), 585 ) 586 587 await Promise.allSettled([feedsPromise, ...listsPromises]) 588 589 resolvedFeeds.forEach(feed => { 590 const hydratedFeed = hydrateFeedGenerator(feed) 591 precacheFeed(queryClient, hydratedFeed) 592 }) 593 resolvedLists.forEach(list => { 594 precacheList(queryClient, list) 595 }) 596 597 const result: SavedFeedItem[] = [] 598 for (let savedItem of savedItems) { 599 if (savedItem.type === 'timeline') { 600 result.push({ 601 type: 'timeline', 602 config: savedItem, 603 view: undefined, 604 }) 605 } else if (savedItem.type === 'feed') { 606 const resolvedFeed = resolvedFeeds.get(savedItem.value) 607 if (resolvedFeed) { 608 result.push({ 609 type: 'feed', 610 config: savedItem, 611 view: resolvedFeed, 612 }) 613 } 614 } else if (savedItem.type === 'list') { 615 const resolvedList = resolvedLists.get(savedItem.value) 616 if (resolvedList) { 617 result.push({ 618 type: 'list', 619 config: savedItem, 620 view: resolvedList, 621 }) 622 } 623 } 624 } 625 626 return { 627 // By this point we know the real count. 628 count: result.length, 629 feeds: result, 630 } 631 }, 632 }) 633} 634 635const feedInfoQueryKeyRoot = 'feedInfo' 636 637export function useFeedInfo(feedUri: string | undefined) { 638 const agent = useAgent() 639 640 return useQuery({ 641 staleTime: STALE.INFINITY, 642 queryKey: [feedInfoQueryKeyRoot, feedUri], 643 queryFn: async () => { 644 if (!feedUri) { 645 return null 646 } 647 648 const res = await agent.app.bsky.feed.getFeedGenerator({ 649 feed: feedUri, 650 }) 651 652 const feedSourceInfo = hydrateFeedGenerator(res.data.view) 653 return feedSourceInfo 654 }, 655 }) 656} 657 658function precacheFeed(queryClient: QueryClient, hydratedFeed: FeedSourceInfo) { 659 precacheResolvedUri( 660 queryClient, 661 hydratedFeed.creatorHandle, 662 hydratedFeed.creatorDid, 663 ) 664 queryClient.setQueryData<FeedSourceInfo>( 665 feedSourceInfoQueryKey({uri: hydratedFeed.uri}), 666 hydratedFeed, 667 ) 668} 669 670export function precacheList( 671 queryClient: QueryClient, 672 list: AppBskyGraphDefs.ListView, 673) { 674 precacheResolvedUri(queryClient, list.creator.handle, list.creator.did) 675 queryClient.setQueryData<AppBskyGraphDefs.ListView>( 676 listQueryKey(list.uri), 677 list, 678 ) 679} 680 681export function precacheFeedFromGeneratorView( 682 queryClient: QueryClient, 683 view: AppBskyFeedDefs.GeneratorView, 684) { 685 const hydratedFeed = hydrateFeedGenerator(view) 686 precacheFeed(queryClient, hydratedFeed) 687}