Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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