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