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