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