Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at main 389 lines 12 kB view raw
1import {useCallback} from 'react' 2import { 3 type $Typed, 4 type AppBskyActorDefs, 5 type AppBskyFeedDefs, 6 AppBskyUnspeccedDefs, 7 type AppBskyUnspeccedGetPostThreadOtherV2, 8 type AppBskyUnspeccedGetPostThreadV2, 9 AtUri, 10} from '@atproto/api' 11import {type QueryClient, useQueryClient} from '@tanstack/react-query' 12 13import { 14 dangerousGetPostShadow, 15 updatePostShadow, 16} from '#/state/cache/post-shadow' 17import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery' 18import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' 19import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' 20import {findAllPostsInQueryData as findAllPostsInPostQueryData} from '#/state/queries/post' 21import {findAllPostsInQueryData as findAllPostsInAlsoLikedQueryData} from '#/state/queries/post-also-liked' 22import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' 23import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' 24import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' 25import {usePostThreadContext} from '#/state/queries/usePostThread' 26import {getBranch} from '#/state/queries/usePostThread/traversal' 27import { 28 type ApiThreadItem, 29 type createPostThreadOtherQueryKey, 30 type createPostThreadQueryKey, 31 type PostThreadParams, 32 postThreadQueryKeyRoot, 33} from '#/state/queries/usePostThread/types' 34import {getRootPostAtUri} from '#/state/queries/usePostThread/utils' 35import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views' 36import { 37 didOrHandleUriMatches, 38 embedViewRecordToPostView, 39 getEmbeddedPost, 40} from '#/state/queries/util' 41 42export function createCacheMutator({ 43 queryClient, 44 postThreadQueryKey, 45 postThreadOtherQueryKey, 46 params, 47}: { 48 queryClient: QueryClient 49 postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey> 50 postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey> 51 params: Pick<PostThreadParams, 'view'> & {below: number} 52}) { 53 return { 54 insertReplies( 55 parentUri: string, 56 replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[], 57 ) { 58 /* 59 * Main thread query mutator. 60 */ 61 queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>( 62 postThreadQueryKey, 63 data => { 64 if (!data) return 65 return { 66 ...data, 67 thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([ 68 ...data.thread, 69 ]), 70 } 71 }, 72 ) 73 74 /* 75 * Additional replies query mutator. 76 */ 77 queryClient.setQueryData<AppBskyUnspeccedGetPostThreadOtherV2.OutputSchema>( 78 postThreadOtherQueryKey, 79 data => { 80 if (!data) return 81 return { 82 ...data, 83 thread: mutator<AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem>([ 84 ...data.thread, 85 ]), 86 } 87 }, 88 ) 89 90 function mutator<T>(thread: ApiThreadItem[]): T[] { 91 for (let i = 0; i < thread.length; i++) { 92 const parent = thread[i] 93 94 if (!AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) continue 95 if (parent.uri !== parentUri) continue 96 97 /* 98 * Update parent data 99 */ 100 const shadow = dangerousGetPostShadow(parent.value.post) 101 const prevOptimisticCount = shadow?.optimisticReplyCount 102 const prevReplyCount = parent.value.post.replyCount 103 // prefer optimistic count, if we already have some 104 const currentReplyCount = 105 (prevOptimisticCount ?? prevReplyCount ?? 0) + 1 106 107 /* 108 * We must update the value in the query cache in order for thread 109 * traversal to properly compute required metadata. 110 */ 111 parent.value.post.replyCount = currentReplyCount 112 113 /** 114 * Additionally, we need to update the post shadow to keep track of 115 * these new values, since mutating the post object above does not 116 * cause a re-render. 117 */ 118 updatePostShadow(queryClient, parent.value.post.uri, { 119 optimisticReplyCount: currentReplyCount, 120 }) 121 122 const opDid = getRootPostAtUri(parent.value.post)?.host 123 const nextPreexistingItem = thread.at(i + 1) 124 const isEndOfReplyChain = 125 !nextPreexistingItem || nextPreexistingItem.depth <= parent.depth 126 const isParentRoot = parent.depth === 0 127 const isParentBelowRoot = parent.depth > 0 128 const optimisticReply = replies.at(0) 129 const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost( 130 optimisticReply?.value, 131 ) 132 ? opDid === optimisticReply.value.post.author.did 133 : false 134 135 /* 136 * Always insert replies if the following conditions are met. Max 137 * depth checks are handled below. 138 */ 139 const canAlwaysInsertReplies = 140 isParentRoot || 141 (params.view === 'tree' && isParentBelowRoot) || 142 (params.view === 'linear' && isEndOfReplyChain) 143 /* 144 * Maybe insert replies if we're in linear view, the replier is the 145 * OP, and certain conditions are met 146 */ 147 const shouldReplaceWithOPReplies = 148 params.view === 'linear' && opIsReplier && isParentBelowRoot 149 150 if (canAlwaysInsertReplies || shouldReplaceWithOPReplies) { 151 const branch = getBranch(thread, i, parent.depth) 152 /* 153 * OP insertions replace other replies _in linear view_. 154 */ 155 const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0 156 const itemsToInsert = replies 157 .map((r, ri) => { 158 r.depth = parent.depth + 1 + ri 159 return r 160 }) 161 .filter(r => { 162 // Filter out replies that are too deep for our UI 163 return r.depth <= params.below 164 }) 165 166 thread.splice(i + 1, itemsToRemove, ...itemsToInsert) 167 } 168 } 169 170 return thread as T[] 171 } 172 }, 173 /** 174 * Unused atm, post shadow does the trick, but it would be nice to clean up 175 * the whole sub-tree on deletes. 176 */ 177 deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) { 178 queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>( 179 postThreadQueryKey, 180 queryData => { 181 if (!queryData) return 182 183 const thread = [...queryData.thread] 184 185 for (let i = 0; i < thread.length; i++) { 186 const existingPost = thread[i] 187 if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue 188 189 if (existingPost.uri === post.uri) { 190 const branch = getBranch(thread, i, existingPost.depth) 191 thread.splice(branch.start, branch.length) 192 break 193 } 194 } 195 196 return { 197 ...queryData, 198 thread, 199 } 200 }, 201 ) 202 }, 203 } 204} 205 206export function getThreadPlaceholder( 207 queryClient: QueryClient, 208 uri: string, 209): $Typed<AppBskyUnspeccedGetPostThreadV2.ThreadItem> | void { 210 let partial 211 for (let item of getThreadPlaceholderCandidates(queryClient, uri)) { 212 /* 213 * Currently, the backend doesn't send full post info in some cases (for 214 * example, for quoted posts). We use missing `likeCount` as a way to 215 * detect that. In the future, we should fix this on the backend, which 216 * will let us always stop on the first result. 217 * 218 * TODO can we send in feeds and quotes? 219 */ 220 const hasAllInfo = item.value.post.likeCount != null 221 if (hasAllInfo) { 222 return item 223 } else { 224 // Keep searching, we might still find a full post in the cache. 225 partial = item 226 } 227 } 228 return partial 229} 230 231export function* getThreadPlaceholderCandidates( 232 queryClient: QueryClient, 233 uri: string, 234): Generator< 235 $Typed< 236 Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & { 237 value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost> 238 } 239 >, 240 void 241> { 242 /* 243 * Check post thread queries first 244 */ 245 for (const post of findAllPostsInQueryData(queryClient, uri)) { 246 yield postViewToThreadPlaceholder(post) 247 } 248 249 /* 250 * Check notifications first. If you have a post in notifications, it's 251 * often due to a like or a repost, and we want to prioritize a post object 252 * with >0 likes/reposts over a stale version with no metrics in order to 253 * avoid a notification->post scroll jump. 254 */ 255 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { 256 yield postViewToThreadPlaceholder(post) 257 } 258 for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { 259 yield postViewToThreadPlaceholder(post) 260 } 261 for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { 262 yield postViewToThreadPlaceholder(post) 263 } 264 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { 265 yield postViewToThreadPlaceholder(post) 266 } 267 for (let post of findAllPostsInAlsoLikedQueryData(queryClient, uri)) { 268 yield postViewToThreadPlaceholder(post) 269 } 270 for (let post of findAllPostsInPostQueryData(queryClient, uri)) { 271 yield postViewToThreadPlaceholder(post) 272 } 273 for (let post of findAllPostsInBookmarksQueryData(queryClient, uri)) { 274 yield postViewToThreadPlaceholder(post) 275 } 276 for (let post of findAllPostsInExploreFeedPreviewsQueryData( 277 queryClient, 278 uri, 279 )) { 280 yield postViewToThreadPlaceholder(post) 281 } 282} 283 284export function* findAllPostsInQueryData( 285 queryClient: QueryClient, 286 uri: string, 287): Generator<AppBskyFeedDefs.PostView, void> { 288 const atUri = new AtUri(uri) 289 const queryDatas = 290 queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({ 291 queryKey: [postThreadQueryKeyRoot], 292 }) 293 294 for (const [_queryKey, queryData] of queryDatas) { 295 if (!queryData) continue 296 297 const {thread} = queryData 298 299 for (const item of thread) { 300 if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 301 if (didOrHandleUriMatches(atUri, item.value.post)) { 302 yield item.value.post 303 } 304 305 const qp = getEmbeddedPost(item.value.post.embed) 306 if (qp && didOrHandleUriMatches(atUri, qp)) { 307 yield embedViewRecordToPostView(qp) 308 } 309 } 310 } 311 } 312} 313 314export function* findAllProfilesInQueryData( 315 queryClient: QueryClient, 316 did: string, 317): Generator<AppBskyActorDefs.ProfileViewBasic, void> { 318 const queryDatas = 319 queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({ 320 queryKey: [postThreadQueryKeyRoot], 321 }) 322 323 for (const [_queryKey, queryData] of queryDatas) { 324 if (!queryData) continue 325 326 const {thread} = queryData 327 328 for (const item of thread) { 329 if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 330 if (item.value.post.author.did === did) { 331 yield item.value.post.author 332 } 333 334 const qp = getEmbeddedPost(item.value.post.embed) 335 if (qp && qp.author.did === did) { 336 yield qp.author 337 } 338 } 339 } 340 } 341} 342 343export function useUpdatePostThreadThreadgateQueryCache() { 344 const qc = useQueryClient() 345 const context = usePostThreadContext() 346 347 return useCallback( 348 (threadgate: AppBskyFeedDefs.ThreadgateView) => { 349 if (!context) return 350 351 function mutator<T>(thread: ApiThreadItem[]): T[] { 352 for (let i = 0; i < thread.length; i++) { 353 const item = thread[i] 354 355 if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) continue 356 357 if (item.depth === 0) { 358 thread.splice(i, 1, { 359 ...item, 360 value: { 361 ...item.value, 362 post: { 363 ...item.value.post, 364 threadgate, 365 }, 366 }, 367 }) 368 } 369 } 370 371 return thread as T[] 372 } 373 374 qc.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>( 375 context.postThreadQueryKey, 376 data => { 377 if (!data) return 378 return { 379 ...data, 380 thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([ 381 ...data.thread, 382 ]), 383 } 384 }, 385 ) 386 }, 387 [qc, context], 388 ) 389}