Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 7da65efc08780b82bb426bcef751e1feaeefb556 447 lines 13 kB view raw
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}