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