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