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