this repo has no description
0
fork

Configure Feed

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

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