my fork of the bluesky client
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}