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