mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
fork

Configure Feed

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

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