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