forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useMemo, useRef} from 'react'
2import {
3 type AppBskyActorDefs,
4 AppBskyFeedDefs,
5 AtUri,
6 moderatePost,
7} from '@atproto/api'
8import {msg} from '@lingui/macro'
9import {useLingui} from '@lingui/react'
10import {
11 type InfiniteData,
12 type QueryClient,
13 useInfiniteQuery,
14} from '@tanstack/react-query'
15
16import {CustomFeedAPI} from '#/lib/api/feed/custom'
17import {aggregateUserInterests} from '#/lib/api/feed/utils'
18import {FeedTuner} from '#/lib/api/feed-manip'
19import {cleanError} from '#/lib/strings/errors'
20import {useModerationOpts} from '#/state/preferences/moderation-opts'
21import {
22 type FeedPostSlice,
23 type FeedPostSliceItem,
24} from '#/state/queries/post-feed'
25import {usePreferencesQuery} from '#/state/queries/preferences'
26import {
27 didOrHandleUriMatches,
28 embedViewRecordToPostView,
29 getEmbeddedPost,
30} from '#/state/queries/util'
31import {useAgent} from '#/state/session'
32
33const RQKEY_ROOT = 'feed-previews'
34const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds]
35
36const LIMIT = 8 // sliced to 6, overfetch to account for moderation
37const PINNED_POST_URIS: Record<string, boolean> = {
38 // 📰 News
39 'at://did:plc:kkf4naxqmweop7dv4l2iqqf5/app.bsky.feed.post/3lgh27w2ngc2b': true,
40 // Gardening
41 'at://did:plc:5rw2on4i56btlcajojaxwcat/app.bsky.feed.post/3kjorckgcwc27': true,
42 // Web Development Trending
43 'at://did:plc:m2sjv3wncvsasdapla35hzwj/app.bsky.feed.post/3lfaw445axs22': true,
44 // Anime & Manga EN
45 'at://did:plc:tazrmeme4dzahimsykusrwrk/app.bsky.feed.post/3knxx2gmkns2y': true,
46 // 📽️ Film
47 'at://did:plc:2hwwem55ce6djnk6bn62cstr/app.bsky.feed.post/3llhpzhbq7c2g': true,
48 // PopSky
49 'at://did:plc:lfdf4srj43iwdng7jn35tjsp/app.bsky.feed.post/3lbblgly65c2g': true,
50 // Science
51 'at://did:plc:hu2obebw3nhfj667522dahfg/app.bsky.feed.post/3kl33otd6ob2s': true,
52 // Birds! 🦉
53 'at://did:plc:ffkgesg3jsv2j7aagkzrtcvt/app.bsky.feed.post/3lbg4r57yk22d': true,
54 // Astronomy
55 'at://did:plc:xy2zorw2ys47poflotxthlzg/app.bsky.feed.post/3kyzye4lujs2w': true,
56 // What's Cooking 🍽️
57 'at://did:plc:geoqe3qls5mwezckxxsewys2/app.bsky.feed.post/3lfqhgvxbqc2q': true,
58 // BookSky 💙📚 #booksky
59 'at://did:plc:geoqe3qls5mwezckxxsewys2/app.bsky.feed.post/3kgrm2rw5ww2e': true,
60}
61
62export type FeedPreviewItem =
63 | {
64 type: 'preview:spacer'
65 key: string
66 }
67 | {
68 type: 'preview:loading'
69 key: string
70 }
71 | {
72 type: 'preview:error'
73 key: string
74 message: string
75 error: string
76 }
77 | {
78 type: 'preview:loadMoreError'
79 key: string
80 }
81 | {
82 type: 'preview:empty'
83 key: string
84 }
85 | {
86 type: 'preview:header'
87 key: string
88 feed: AppBskyFeedDefs.GeneratorView
89 }
90 | {
91 type: 'preview:footer'
92 key: string
93 }
94 // copied from PostFeed.tsx
95 | {
96 type: 'preview:sliceItem'
97 key: string
98 slice: FeedPostSlice
99 indexInSlice: number
100 feed: AppBskyFeedDefs.GeneratorView
101 showReplyTo: boolean
102 hideTopBorder: boolean
103 }
104 | {
105 type: 'preview:sliceViewFullThread'
106 key: string
107 uri: string
108 }
109
110export function useFeedPreviews(
111 feedsMaybeWithDuplicates: AppBskyFeedDefs.GeneratorView[],
112 isEnabled: boolean = true,
113) {
114 const feeds = useMemo(
115 () =>
116 feedsMaybeWithDuplicates.filter(
117 (f, i, a) => i === a.findIndex(f2 => f.uri === f2.uri),
118 ),
119 [feedsMaybeWithDuplicates],
120 )
121
122 const uris = feeds.map(feed => feed.uri)
123 const {_} = useLingui()
124 const agent = useAgent()
125 const {data: preferences} = usePreferencesQuery()
126 const userInterests = aggregateUserInterests(preferences)
127 const moderationOpts = useModerationOpts()
128 const enabled = feeds.length > 0 && isEnabled
129
130 const processedPageCache = useRef(
131 new Map<
132 {
133 feed: AppBskyFeedDefs.GeneratorView
134 posts: AppBskyFeedDefs.FeedViewPost[]
135 },
136 FeedPreviewItem[]
137 >(),
138 )
139
140 const query = useInfiniteQuery({
141 enabled,
142 queryKey: RQKEY(uris),
143 queryFn: async ({pageParam}) => {
144 const feed = feeds[pageParam]
145 const api = new CustomFeedAPI({
146 agent,
147 feedParams: {feed: feed.uri},
148 userInterests,
149 })
150 const data = await api.fetch({cursor: undefined, limit: LIMIT})
151 return {
152 feed,
153 posts: data.feed,
154 }
155 },
156 initialPageParam: 0,
157 getNextPageParam: (_p, _a, count) =>
158 count < feeds.length ? count + 1 : undefined,
159 })
160
161 const {data, isFetched, isError, isPending, error} = query
162
163 return {
164 query,
165 data: useMemo<FeedPreviewItem[]>(() => {
166 const items: FeedPreviewItem[] = []
167
168 if (!enabled) return items
169
170 items.push({
171 type: 'preview:spacer',
172 key: 'spacer',
173 })
174
175 const isEmpty =
176 !isPending && !data?.pages?.some(page => page.posts.length)
177
178 if (isFetched) {
179 if (isError && isEmpty) {
180 items.push({
181 type: 'preview:error',
182 key: 'error',
183 message: _(msg`An error occurred while fetching the feed.`),
184 error: cleanError(error),
185 })
186 } else if (isEmpty) {
187 items.push({
188 type: 'preview:empty',
189 key: 'empty',
190 })
191 } else if (data) {
192 for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex++) {
193 const page = data.pages[pageIndex]
194
195 const cachedPage = processedPageCache.current.get(page)
196 if (cachedPage) {
197 items.push(...cachedPage)
198 continue
199 }
200
201 // default feed tuner - we just want it to slice up the feed
202 const tuner = new FeedTuner([])
203 const slices: FeedPreviewItem[] = []
204
205 let rowIndex = 0
206 for (const item of tuner.tune(page.posts)) {
207 if (item.isFallbackMarker) continue
208
209 const moderations = item.items.map(item =>
210 moderatePost(item.post, moderationOpts!),
211 )
212
213 // apply moderation filters
214 item.items = item.items.filter((_, i) => {
215 return !moderations[i]?.ui('contentList').filter
216 })
217
218 const slice = {
219 _reactKey: page.feed.uri + item._reactKey,
220 _isFeedPostSlice: true,
221 isFallbackMarker: false,
222 isIncompleteThread: item.isIncompleteThread,
223 feedContext: item.feedContext,
224 reqId: item.reqId,
225 reason: item.reason,
226 feedPostUri: item.feedPostUri,
227 items: item.items
228 .slice(0, 6)
229 .filter(subItem => {
230 return !PINNED_POST_URIS[subItem.post.uri]
231 })
232 .map((subItem, i) => {
233 const feedPostSliceItem: FeedPostSliceItem = {
234 _reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`,
235 uri: subItem.post.uri,
236 post: subItem.post,
237 record: subItem.record,
238 moderation: moderations[i],
239 parentAuthor: subItem.parentAuthor,
240 isParentBlocked: subItem.isParentBlocked,
241 isParentNotFound: subItem.isParentNotFound,
242 }
243 return feedPostSliceItem
244 }),
245 }
246 if (slice.isIncompleteThread && slice.items.length >= 3) {
247 const beforeLast = slice.items.length - 2
248 const last = slice.items.length - 1
249 slices.push({
250 type: 'preview:sliceItem',
251 key: slice.items[0]._reactKey,
252 slice: slice,
253 indexInSlice: 0,
254 feed: page.feed,
255 showReplyTo: false,
256 hideTopBorder: rowIndex === 0,
257 })
258 slices.push({
259 type: 'preview:sliceViewFullThread',
260 key: slice._reactKey + '-viewFullThread',
261 uri: slice.items[0].uri,
262 })
263 slices.push({
264 type: 'preview:sliceItem',
265 key: slice.items[beforeLast]._reactKey,
266 slice: slice,
267 indexInSlice: beforeLast,
268 feed: page.feed,
269 showReplyTo:
270 slice.items[beforeLast].parentAuthor?.did !==
271 slice.items[beforeLast].post.author.did,
272 hideTopBorder: false,
273 })
274 slices.push({
275 type: 'preview:sliceItem',
276 key: slice.items[last]._reactKey,
277 slice: slice,
278 indexInSlice: last,
279 feed: page.feed,
280 showReplyTo: false,
281 hideTopBorder: false,
282 })
283 } else {
284 for (let i = 0; i < slice.items.length; i++) {
285 slices.push({
286 type: 'preview:sliceItem',
287 key: slice.items[i]._reactKey,
288 slice: slice,
289 indexInSlice: i,
290 feed: page.feed,
291 showReplyTo: i === 0,
292 hideTopBorder: i === 0 && rowIndex === 0,
293 })
294 }
295 }
296
297 rowIndex++
298 }
299
300 let processedPage: FeedPreviewItem[]
301
302 if (slices.length > 0) {
303 processedPage = [
304 {
305 type: 'preview:header',
306 key: `header-${page.feed.uri}`,
307 feed: page.feed,
308 },
309 ...slices,
310 {
311 type: 'preview:footer',
312 key: `footer-${page.feed.uri}`,
313 },
314 ]
315 } else {
316 processedPage = []
317 }
318
319 processedPageCache.current.set(page, processedPage)
320 items.push(...processedPage)
321 }
322 } else if (isError && !isEmpty) {
323 items.push({
324 type: 'preview:loadMoreError',
325 key: 'loadMoreError',
326 })
327 }
328 } else {
329 items.push({
330 type: 'preview:loading',
331 key: 'loading',
332 })
333 }
334
335 return items
336 }, [
337 enabled,
338 data,
339 isFetched,
340 isError,
341 isPending,
342 moderationOpts,
343 _,
344 error,
345 ]),
346 }
347}
348
349export function* findAllPostsInQueryData(
350 queryClient: QueryClient,
351 uri: string,
352): Generator<AppBskyFeedDefs.PostView, undefined> {
353 const atUri = new AtUri(uri)
354
355 const queryDatas = queryClient.getQueriesData<
356 InfiniteData<{
357 feed: AppBskyFeedDefs.GeneratorView
358 posts: AppBskyFeedDefs.FeedViewPost[]
359 }>
360 >({
361 queryKey: [RQKEY_ROOT],
362 })
363 for (const [_queryKey, queryData] of queryDatas) {
364 if (!queryData?.pages) {
365 continue
366 }
367 for (const page of queryData?.pages) {
368 for (const item of page.posts) {
369 if (didOrHandleUriMatches(atUri, item.post)) {
370 yield item.post
371 }
372
373 const quotedPost = getEmbeddedPost(item.post.embed)
374 if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
375 yield embedViewRecordToPostView(quotedPost)
376 }
377
378 if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
379 if (didOrHandleUriMatches(atUri, item.reply.parent)) {
380 yield item.reply.parent
381 }
382
383 const parentQuotedPost = getEmbeddedPost(item.reply.parent.embed)
384 if (
385 parentQuotedPost &&
386 didOrHandleUriMatches(atUri, parentQuotedPost)
387 ) {
388 yield embedViewRecordToPostView(parentQuotedPost)
389 }
390 }
391
392 if (AppBskyFeedDefs.isPostView(item.reply?.root)) {
393 if (didOrHandleUriMatches(atUri, item.reply.root)) {
394 yield item.reply.root
395 }
396
397 const rootQuotedPost = getEmbeddedPost(item.reply.root.embed)
398 if (rootQuotedPost && didOrHandleUriMatches(atUri, rootQuotedPost)) {
399 yield embedViewRecordToPostView(rootQuotedPost)
400 }
401 }
402 }
403 }
404 }
405}
406
407export function* findAllProfilesInQueryData(
408 queryClient: QueryClient,
409 did: string,
410): Generator<AppBskyActorDefs.ProfileViewBasic, undefined> {
411 const queryDatas = queryClient.getQueriesData<
412 InfiniteData<{
413 feed: AppBskyFeedDefs.GeneratorView
414 posts: AppBskyFeedDefs.FeedViewPost[]
415 }>
416 >({
417 queryKey: [RQKEY_ROOT],
418 })
419 for (const [_queryKey, queryData] of queryDatas) {
420 if (!queryData?.pages) {
421 continue
422 }
423 for (const page of queryData?.pages) {
424 for (const item of page.posts) {
425 if (item.post.author.did === did) {
426 yield item.post.author
427 }
428 const quotedPost = getEmbeddedPost(item.post.embed)
429 if (quotedPost?.author.did === did) {
430 yield quotedPost.author
431 }
432 if (
433 AppBskyFeedDefs.isPostView(item.reply?.parent) &&
434 item.reply?.parent?.author.did === did
435 ) {
436 yield item.reply.parent.author
437 }
438 if (
439 AppBskyFeedDefs.isPostView(item.reply?.root) &&
440 item.reply?.root?.author.did === did
441 ) {
442 yield item.reply.root.author
443 }
444 }
445 }
446 }
447}