fork
Configure Feed
Select the types of activity you want to include in your feed.
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.
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}