Bluesky app fork with some witchin' additions 💫

Remove remaining usages of old post thread query (#9184)

* Remove remaining usages of old post thread query

* Add PostThreadContext, cache mutator for threadgates on threads, pipe it through

* Replace getPostThread in threadgate query

* Replace in initQuote handling, which isn't even used rn...

* Missing import

* Revert ext change

authored by

Eric Bailey and committed by
GitHub
0b50e9f1 0f6a99c8

+191 -730
+15 -4
src/components/dialogs/PostInteractionSettingsDialog.tsx
··· 13 13 import {logger} from '#/logger' 14 14 import {STALE} from '#/state/queries' 15 15 import {useMyListsQuery} from '#/state/queries/my-lists' 16 + import {useGetPost} from '#/state/queries/post' 16 17 import { 17 18 createPostgateQueryKey, 18 19 getPostgateRecord, ··· 25 26 } from '#/state/queries/postgate/util' 26 27 import { 27 28 createThreadgateViewQueryKey, 28 - getThreadgateView, 29 29 type ThreadgateAllowUISetting, 30 30 threadgateViewToAllowUISetting, 31 31 useSetThreadgateAllowMutation, 32 32 useThreadgateViewQuery, 33 33 } from '#/state/queries/threadgate' 34 + import { 35 + PostThreadContextProvider, 36 + usePostThreadContext, 37 + } from '#/state/queries/usePostThread' 34 38 import {useAgent, useSession} from '#/state/session' 35 39 import * as Toast from '#/view/com/util/Toast' 36 40 import {atoms as a, useTheme} from '#/alf' ··· 133 137 export function PostInteractionSettingsDialog( 134 138 props: PostInteractionSettingsDialogProps, 135 139 ) { 140 + const postThreadContext = usePostThreadContext() 136 141 return ( 137 142 <Dialog.Outer control={props.control}> 138 143 <Dialog.Handle /> 139 - <PostInteractionSettingsDialogControlledInner {...props} /> 144 + <PostThreadContextProvider context={postThreadContext}> 145 + <PostInteractionSettingsDialogControlledInner {...props} /> 146 + </PostThreadContextProvider> 140 147 </Dialog.Outer> 141 148 ) 142 149 } ··· 558 565 }) { 559 566 const queryClient = useQueryClient() 560 567 const agent = useAgent() 568 + const getPost = useGetPost() 561 569 562 570 return React.useCallback(async () => { 563 571 try { ··· 570 578 }), 571 579 queryClient.prefetchQuery({ 572 580 queryKey: createThreadgateViewQueryKey(rootPostUri), 573 - queryFn: () => getThreadgateView({agent, postUri: rootPostUri}), 581 + queryFn: async () => { 582 + const post = await getPost({uri: rootPostUri}) 583 + return post.threadgate ?? null 584 + }, 574 585 staleTime: STALE.SECONDS.THIRTY, 575 586 }), 576 587 ]) ··· 579 590 safeMessage: e.message, 580 591 }) 581 592 } 582 - }, [queryClient, agent, postUri, rootPostUri]) 593 + }, [queryClient, agent, postUri, rootPostUri, getPost]) 583 594 }
+4 -4
src/screens/Post/PostLikedBy.tsx
··· 7 7 type NativeStackScreenProps, 8 8 } from '#/lib/routes/types' 9 9 import {makeRecordUri} from '#/lib/strings/url-helpers' 10 - import {usePostThreadQuery} from '#/state/queries/post-thread' 10 + import {usePostQuery} from '#/state/queries/post' 11 11 import {useSetMinimalShellMode} from '#/state/shell' 12 12 import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy' 13 13 import * as Layout from '#/components/Layout' ··· 17 17 const setMinimalShellMode = useSetMinimalShellMode() 18 18 const {name, rkey} = route.params 19 19 const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) 20 - const {data: post} = usePostThreadQuery(uri) 20 + const {data: post} = usePostQuery(uri) 21 21 22 22 let likeCount 23 - if (post?.thread.type === 'post') { 24 - likeCount = post.thread.post.likeCount 23 + if (post) { 24 + likeCount = post.likeCount 25 25 } 26 26 27 27 useFocusEffect(
+4 -4
src/screens/Post/PostQuotes.tsx
··· 7 7 type NativeStackScreenProps, 8 8 } from '#/lib/routes/types' 9 9 import {makeRecordUri} from '#/lib/strings/url-helpers' 10 - import {usePostThreadQuery} from '#/state/queries/post-thread' 10 + import {usePostQuery} from '#/state/queries/post' 11 11 import {useSetMinimalShellMode} from '#/state/shell' 12 12 import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes' 13 13 import * as Layout from '#/components/Layout' ··· 17 17 const setMinimalShellMode = useSetMinimalShellMode() 18 18 const {name, rkey} = route.params 19 19 const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) 20 - const {data: post} = usePostThreadQuery(uri) 20 + const {data: post} = usePostQuery(uri) 21 21 22 22 let quoteCount 23 - if (post?.thread.type === 'post') { 24 - quoteCount = post.thread.post.quoteCount 23 + if (post) { 24 + quoteCount = post.quoteCount 25 25 } 26 26 27 27 useFocusEffect(
+4 -4
src/screens/Post/PostRepostedBy.tsx
··· 7 7 type NativeStackScreenProps, 8 8 } from '#/lib/routes/types' 9 9 import {makeRecordUri} from '#/lib/strings/url-helpers' 10 - import {usePostThreadQuery} from '#/state/queries/post-thread' 10 + import {usePostQuery} from '#/state/queries/post' 11 11 import {useSetMinimalShellMode} from '#/state/shell' 12 12 import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy' 13 13 import * as Layout from '#/components/Layout' ··· 17 17 const {name, rkey} = route.params 18 18 const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) 19 19 const setMinimalShellMode = useSetMinimalShellMode() 20 - const {data: post} = usePostThreadQuery(uri) 20 + const {data: post} = usePostQuery(uri) 21 21 22 22 let quoteCount 23 - if (post?.thread.type === 'post') { 24 - quoteCount = post.thread.post.repostCount 23 + if (post) { 24 + quoteCount = post.repostCount 25 25 } 26 26 27 27 useFocusEffect(
+7 -3
src/screens/PostThread/index.tsx
··· 7 7 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 8 8 import {useFeedFeedback} from '#/state/feed-feedback' 9 9 import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' 10 - import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread' 10 + import { 11 + PostThreadContextProvider, 12 + type ThreadItem, 13 + usePostThread, 14 + } from '#/state/queries/usePostThread' 11 15 import {useSession} from '#/state/session' 12 16 import {type OnPostSuccessData} from '#/state/shell/composer' 13 17 import {useShellLayout} from '#/state/shell/shell-layout' ··· 492 496 const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined 493 497 494 498 return ( 495 - <> 499 + <PostThreadContextProvider context={thread.context}> 496 500 <Layout.Header.Outer headerRef={headerRef}> 497 501 <Layout.Header.BackButton /> 498 502 <Layout.Header.Content> ··· 575 579 {!gtMobile && canReply && hasSession && ( 576 580 <MobileComposePrompt onPressReply={onReplyToAnchor} /> 577 581 )} 578 - </> 582 + </PostThreadContextProvider> 579 583 ) 580 584 } 581 585
-6
src/state/cache/post-shadow.ts
··· 12 12 import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' 13 13 import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' 14 14 import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' 15 - import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread' 16 15 import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' 17 16 import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' 18 17 import {castAsShadow, type Shadow} from './types' ··· 175 174 } 176 175 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { 177 176 yield post 178 - } 179 - for (let node of findAllPostsInThreadQueryData(queryClient, uri)) { 180 - if (node.type === 'post') { 181 - yield node.post 182 - } 183 177 } 184 178 for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) { 185 179 yield post
-2
src/state/cache/profile-shadow.ts
··· 16 16 import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by' 17 17 import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes' 18 18 import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by' 19 - import {findAllProfilesInQueryData as findAllProfilesInPostThreadQueryData} from '#/state/queries/post-thread' 20 19 import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '#/state/queries/profile' 21 20 import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '#/state/queries/profile-followers' 22 21 import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows' ··· 173 172 yield* findAllProfilesInActorSearchQueryData(queryClient, did) 174 173 yield* findAllProfilesInListConvosQueryData(queryClient, did) 175 174 yield* findAllProfilesInFeedsQueryData(queryClient, did) 176 - yield* findAllProfilesInPostThreadQueryData(queryClient, did) 177 175 yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) 178 176 yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) 179 177 yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
-631
src/state/queries/post-thread.ts
··· 1 - import { 2 - type AppBskyActorDefs, 3 - type AppBskyEmbedRecord, 4 - AppBskyFeedDefs, 5 - type AppBskyFeedGetPostThread, 6 - AppBskyFeedPost, 7 - AtUri, 8 - moderatePost, 9 - type ModerationDecision, 10 - type ModerationOpts, 11 - } from '@atproto/api' 12 - import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' 13 - 14 - import { 15 - findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData, 16 - findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData, 17 - } from '#/state/queries/explore-feed-previews' 18 - import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' 19 - import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 20 - import { 21 - findAllPostsInQueryData as findAllPostsInSearchQueryData, 22 - findAllProfilesInQueryData as findAllProfilesInSearchQueryData, 23 - } from '#/state/queries/search-posts' 24 - import {useAgent} from '#/state/session' 25 - import * as bsky from '#/types/bsky' 26 - import { 27 - findAllPostsInQueryData as findAllPostsInNotifsQueryData, 28 - findAllProfilesInQueryData as findAllProfilesInNotifsQueryData, 29 - } from './notifications/feed' 30 - import { 31 - findAllPostsInQueryData as findAllPostsInFeedQueryData, 32 - findAllProfilesInQueryData as findAllProfilesInFeedQueryData, 33 - } from './post-feed' 34 - import { 35 - didOrHandleUriMatches, 36 - embedViewRecordToPostView, 37 - getEmbeddedPost, 38 - } from './util' 39 - 40 - const REPLY_TREE_DEPTH = 10 41 - export const RQKEY_ROOT = 'post-thread' 42 - export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] 43 - type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] 44 - 45 - export interface ThreadCtx { 46 - depth: number 47 - isHighlightedPost?: boolean 48 - hasMore?: boolean 49 - isParentLoading?: boolean 50 - isChildLoading?: boolean 51 - isSelfThread?: boolean 52 - hasMoreSelfThread?: boolean 53 - } 54 - 55 - export type ThreadPost = { 56 - type: 'post' 57 - _reactKey: string 58 - uri: string 59 - post: AppBskyFeedDefs.PostView 60 - record: AppBskyFeedPost.Record 61 - parent: ThreadNode | undefined 62 - replies: ThreadNode[] | undefined 63 - hasOPLike: boolean | undefined 64 - ctx: ThreadCtx 65 - } 66 - 67 - export type ThreadNotFound = { 68 - type: 'not-found' 69 - _reactKey: string 70 - uri: string 71 - ctx: ThreadCtx 72 - } 73 - 74 - export type ThreadBlocked = { 75 - type: 'blocked' 76 - _reactKey: string 77 - uri: string 78 - ctx: ThreadCtx 79 - } 80 - 81 - export type ThreadUnknown = { 82 - type: 'unknown' 83 - uri: string 84 - } 85 - 86 - export type ThreadNode = 87 - | ThreadPost 88 - | ThreadNotFound 89 - | ThreadBlocked 90 - | ThreadUnknown 91 - 92 - export type ThreadModerationCache = WeakMap<ThreadNode, ModerationDecision> 93 - 94 - export type PostThreadQueryData = { 95 - thread: ThreadNode 96 - threadgate?: AppBskyFeedDefs.ThreadgateView 97 - } 98 - 99 - export function usePostThreadQuery(uri: string | undefined) { 100 - const queryClient = useQueryClient() 101 - const agent = useAgent() 102 - return useQuery<PostThreadQueryData, Error>({ 103 - gcTime: 0, 104 - queryKey: RQKEY(uri || ''), 105 - async queryFn() { 106 - const res = await agent.getPostThread({ 107 - uri: uri!, 108 - depth: REPLY_TREE_DEPTH, 109 - }) 110 - if (res.success) { 111 - const thread = responseToThreadNodes(res.data.thread) 112 - annotateSelfThread(thread) 113 - return { 114 - thread, 115 - threadgate: res.data.threadgate as 116 - | AppBskyFeedDefs.ThreadgateView 117 - | undefined, 118 - } 119 - } 120 - return {thread: {type: 'unknown', uri: uri!}} 121 - }, 122 - enabled: !!uri, 123 - placeholderData: () => { 124 - if (!uri) return 125 - const post = findPostInQueryData(queryClient, uri) 126 - if (post) { 127 - return {thread: post} 128 - } 129 - return undefined 130 - }, 131 - }) 132 - } 133 - 134 - export function fillThreadModerationCache( 135 - cache: ThreadModerationCache, 136 - node: ThreadNode, 137 - moderationOpts: ModerationOpts, 138 - ) { 139 - if (node.type === 'post') { 140 - cache.set(node, moderatePost(node.post, moderationOpts)) 141 - if (node.parent) { 142 - fillThreadModerationCache(cache, node.parent, moderationOpts) 143 - } 144 - if (node.replies) { 145 - for (const reply of node.replies) { 146 - fillThreadModerationCache(cache, reply, moderationOpts) 147 - } 148 - } 149 - } 150 - } 151 - 152 - export function sortThread( 153 - node: ThreadNode, 154 - opts: UsePreferencesQueryResponse['threadViewPrefs'], 155 - modCache: ThreadModerationCache, 156 - currentDid: string | undefined, 157 - justPostedUris: Set<string>, 158 - threadgateRecordHiddenReplies: Set<string>, 159 - fetchedAtCache: Map<string, number>, 160 - fetchedAt: number, 161 - randomCache: Map<string, number>, 162 - ): ThreadNode { 163 - if (node.type !== 'post') { 164 - return node 165 - } 166 - if (node.replies) { 167 - node.replies.sort((a: ThreadNode, b: ThreadNode) => { 168 - if (a.type !== 'post') { 169 - return 1 170 - } 171 - if (b.type !== 'post') { 172 - return -1 173 - } 174 - 175 - if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) { 176 - const aIsJustPosted = 177 - a.post.author.did === currentDid && justPostedUris.has(a.post.uri) 178 - const bIsJustPosted = 179 - b.post.author.did === currentDid && justPostedUris.has(b.post.uri) 180 - if (aIsJustPosted && bIsJustPosted) { 181 - return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest 182 - } else if (aIsJustPosted) { 183 - return -1 // reply while onscreen 184 - } else if (bIsJustPosted) { 185 - return 1 // reply while onscreen 186 - } 187 - } 188 - 189 - const aIsByOp = a.post.author.did === node.post?.author.did 190 - const bIsByOp = b.post.author.did === node.post?.author.did 191 - if (aIsByOp && bIsByOp) { 192 - return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest 193 - } else if (aIsByOp) { 194 - return -1 // op's own reply 195 - } else if (bIsByOp) { 196 - return 1 // op's own reply 197 - } 198 - 199 - const aIsBySelf = a.post.author.did === currentDid 200 - const bIsBySelf = b.post.author.did === currentDid 201 - if (aIsBySelf && bIsBySelf) { 202 - return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest 203 - } else if (aIsBySelf) { 204 - return -1 // current account's reply 205 - } else if (bIsBySelf) { 206 - return 1 // current account's reply 207 - } 208 - 209 - const aHidden = threadgateRecordHiddenReplies.has(a.uri) 210 - const bHidden = threadgateRecordHiddenReplies.has(b.uri) 211 - if (aHidden && !aIsBySelf && !bHidden) { 212 - return 1 213 - } else if (bHidden && !bIsBySelf && !aHidden) { 214 - return -1 215 - } 216 - 217 - const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur) 218 - const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur) 219 - if (aBlur !== bBlur) { 220 - if (aBlur) { 221 - return 1 222 - } 223 - if (bBlur) { 224 - return -1 225 - } 226 - } 227 - 228 - const aPin = Boolean(a.record.text.trim() === '📌') 229 - const bPin = Boolean(b.record.text.trim() === '📌') 230 - if (aPin !== bPin) { 231 - if (aPin) { 232 - return 1 233 - } 234 - if (bPin) { 235 - return -1 236 - } 237 - } 238 - 239 - if (opts.prioritizeFollowedUsers) { 240 - const af = a.post.author.viewer?.following 241 - const bf = b.post.author.viewer?.following 242 - if (af && !bf) { 243 - return -1 244 - } else if (!af && bf) { 245 - return 1 246 - } 247 - } 248 - 249 - // Split items from different fetches into separate generations. 250 - let aFetchedAt = fetchedAtCache.get(a.uri) 251 - if (aFetchedAt === undefined) { 252 - fetchedAtCache.set(a.uri, fetchedAt) 253 - aFetchedAt = fetchedAt 254 - } 255 - let bFetchedAt = fetchedAtCache.get(b.uri) 256 - if (bFetchedAt === undefined) { 257 - fetchedAtCache.set(b.uri, fetchedAt) 258 - bFetchedAt = fetchedAt 259 - } 260 - 261 - if (aFetchedAt !== bFetchedAt) { 262 - return aFetchedAt - bFetchedAt // older fetches first 263 - } else if (opts.sort === 'hotness') { 264 - const aHotness = getHotness(a, aFetchedAt) 265 - const bHotness = getHotness(b, bFetchedAt /* same as aFetchedAt */) 266 - return bHotness - aHotness 267 - } else if (opts.sort === 'oldest') { 268 - return a.post.indexedAt.localeCompare(b.post.indexedAt) 269 - } else if (opts.sort === 'newest') { 270 - return b.post.indexedAt.localeCompare(a.post.indexedAt) 271 - } else if (opts.sort === 'most-likes') { 272 - if (a.post.likeCount === b.post.likeCount) { 273 - return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest 274 - } else { 275 - return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes 276 - } 277 - } else if (opts.sort === 'random') { 278 - let aRandomScore = randomCache.get(a.uri) 279 - if (aRandomScore === undefined) { 280 - aRandomScore = Math.random() 281 - randomCache.set(a.uri, aRandomScore) 282 - } 283 - let bRandomScore = randomCache.get(b.uri) 284 - if (bRandomScore === undefined) { 285 - bRandomScore = Math.random() 286 - randomCache.set(b.uri, bRandomScore) 287 - } 288 - // this is vaguely criminal but we can get away with it 289 - return aRandomScore - bRandomScore 290 - } else { 291 - return b.post.indexedAt.localeCompare(a.post.indexedAt) 292 - } 293 - }) 294 - node.replies.forEach(reply => 295 - sortThread( 296 - reply, 297 - opts, 298 - modCache, 299 - currentDid, 300 - justPostedUris, 301 - threadgateRecordHiddenReplies, 302 - fetchedAtCache, 303 - fetchedAt, 304 - randomCache, 305 - ), 306 - ) 307 - } 308 - return node 309 - } 310 - 311 - // internal methods 312 - // = 313 - 314 - // Inspired by https://join-lemmy.org/docs/contributors/07-ranking-algo.html 315 - // We want to give recent comments a real chance (and not bury them deep below the fold) 316 - // while also surfacing well-liked comments from the past. In the future, we can explore 317 - // something more sophisticated, but we don't have much data on the client right now. 318 - function getHotness(threadPost: ThreadPost, fetchedAt: number) { 319 - const {post, hasOPLike} = threadPost 320 - const hoursAgo = Math.max( 321 - 0, 322 - (new Date(fetchedAt).getTime() - new Date(post.indexedAt).getTime()) / 323 - (1000 * 60 * 60), 324 - ) 325 - const likeCount = post.likeCount ?? 0 326 - const likeOrder = Math.log(3 + likeCount) * (hasOPLike ? 1.45 : 1.0) 327 - const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount)) 328 - const opLikeBoost = hasOPLike ? 0.8 : 1.0 329 - const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent * opLikeBoost) 330 - return likeOrder / timePenalty 331 - } 332 - 333 - function responseToThreadNodes( 334 - node: ThreadViewNode, 335 - depth = 0, 336 - direction: 'up' | 'down' | 'start' = 'start', 337 - ): ThreadNode { 338 - if ( 339 - AppBskyFeedDefs.isThreadViewPost(node) && 340 - bsky.dangerousIsType<AppBskyFeedPost.Record>( 341 - node.post.record, 342 - AppBskyFeedPost.isRecord, 343 - ) 344 - ) { 345 - const post = node.post 346 - // These should normally be present. They're missing only for 347 - // posts that were *just* created. Ideally, the backend would 348 - // know to return zeros. Fill them in manually to compensate. 349 - post.replyCount ??= 0 350 - post.likeCount ??= 0 351 - post.repostCount ??= 0 352 - return { 353 - type: 'post', 354 - _reactKey: node.post.uri, 355 - uri: node.post.uri, 356 - post: post, 357 - record: node.post.record, 358 - parent: 359 - node.parent && direction !== 'down' 360 - ? responseToThreadNodes(node.parent, depth - 1, 'up') 361 - : undefined, 362 - replies: 363 - node.replies?.length && direction !== 'up' 364 - ? node.replies 365 - .map(reply => responseToThreadNodes(reply, depth + 1, 'down')) 366 - // do not show blocked posts in replies 367 - .filter(node => node.type !== 'blocked') 368 - : undefined, 369 - hasOPLike: Boolean(node?.threadContext?.rootAuthorLike), 370 - ctx: { 371 - depth, 372 - isHighlightedPost: depth === 0, 373 - hasMore: 374 - direction === 'down' && !node.replies?.length && !!post.replyCount, 375 - isSelfThread: false, // populated `annotateSelfThread` 376 - hasMoreSelfThread: false, // populated in `annotateSelfThread` 377 - }, 378 - } 379 - } else if (AppBskyFeedDefs.isBlockedPost(node)) { 380 - return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}} 381 - } else if (AppBskyFeedDefs.isNotFoundPost(node)) { 382 - return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}} 383 - } else { 384 - return {type: 'unknown', uri: ''} 385 - } 386 - } 387 - 388 - function annotateSelfThread(thread: ThreadNode) { 389 - if (thread.type !== 'post') { 390 - return 391 - } 392 - const selfThreadNodes: ThreadPost[] = [thread] 393 - 394 - let parent: ThreadNode | undefined = thread.parent 395 - while (parent) { 396 - if ( 397 - parent.type !== 'post' || 398 - parent.post.author.did !== thread.post.author.did 399 - ) { 400 - // not a self-thread 401 - return 402 - } 403 - selfThreadNodes.unshift(parent) 404 - parent = parent.parent 405 - } 406 - 407 - let node = thread 408 - for (let i = 0; i < 10; i++) { 409 - const reply = node.replies?.find( 410 - r => r.type === 'post' && r.post.author.did === thread.post.author.did, 411 - ) 412 - if (reply?.type !== 'post') { 413 - break 414 - } 415 - selfThreadNodes.push(reply) 416 - node = reply 417 - } 418 - 419 - if (selfThreadNodes.length > 1) { 420 - for (const selfThreadNode of selfThreadNodes) { 421 - selfThreadNode.ctx.isSelfThread = true 422 - } 423 - const last = selfThreadNodes[selfThreadNodes.length - 1] 424 - if ( 425 - last && 426 - last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth 427 - last.post.replyCount && // has replies 428 - !last.replies?.length // replies were not hydrated 429 - ) { 430 - last.ctx.hasMoreSelfThread = true 431 - } 432 - } 433 - } 434 - 435 - function findPostInQueryData( 436 - queryClient: QueryClient, 437 - uri: string, 438 - ): ThreadNode | void { 439 - let partial 440 - for (let item of findAllPostsInQueryData(queryClient, uri)) { 441 - if (item.type === 'post') { 442 - // Currently, the backend doesn't send full post info in some cases 443 - // (for example, for quoted posts). We use missing `likeCount` 444 - // as a way to detect that. In the future, we should fix this on 445 - // the backend, which will let us always stop on the first result. 446 - const hasAllInfo = item.post.likeCount != null 447 - if (hasAllInfo) { 448 - return item 449 - } else { 450 - partial = item 451 - // Keep searching, we might still find a full post in the cache. 452 - } 453 - } 454 - } 455 - return partial 456 - } 457 - 458 - export function* findAllPostsInQueryData( 459 - queryClient: QueryClient, 460 - uri: string, 461 - ): Generator<ThreadNode, void> { 462 - const atUri = new AtUri(uri) 463 - 464 - const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({ 465 - queryKey: [RQKEY_ROOT], 466 - }) 467 - for (const [_queryKey, queryData] of queryDatas) { 468 - if (!queryData) { 469 - continue 470 - } 471 - const {thread} = queryData 472 - for (const item of traverseThread(thread)) { 473 - if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) { 474 - const placeholder = threadNodeToPlaceholderThread(item) 475 - if (placeholder) { 476 - yield placeholder 477 - } 478 - } 479 - const quotedPost = 480 - item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined 481 - if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { 482 - yield embedViewRecordToPlaceholderThread(quotedPost) 483 - } 484 - } 485 - } 486 - for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { 487 - // Check notifications first. If you have a post in notifications, 488 - // it's often due to a like or a repost, and we want to prioritize 489 - // a post object with >0 likes/reposts over a stale version with no 490 - // metrics in order to avoid a notification->post scroll jump. 491 - yield postViewToPlaceholderThread(post) 492 - } 493 - for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { 494 - yield postViewToPlaceholderThread(post) 495 - } 496 - for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { 497 - yield postViewToPlaceholderThread(post) 498 - } 499 - for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { 500 - yield postViewToPlaceholderThread(post) 501 - } 502 - for (let post of findAllPostsInExploreFeedPreviewsQueryData( 503 - queryClient, 504 - uri, 505 - )) { 506 - yield postViewToPlaceholderThread(post) 507 - } 508 - } 509 - 510 - export function* findAllProfilesInQueryData( 511 - queryClient: QueryClient, 512 - did: string, 513 - ): Generator<AppBskyActorDefs.ProfileViewBasic, void> { 514 - const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({ 515 - queryKey: [RQKEY_ROOT], 516 - }) 517 - for (const [_queryKey, queryData] of queryDatas) { 518 - if (!queryData) { 519 - continue 520 - } 521 - const {thread} = queryData 522 - for (const item of traverseThread(thread)) { 523 - if (item.type === 'post' && item.post.author.did === did) { 524 - yield item.post.author 525 - } 526 - const quotedPost = 527 - item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined 528 - if (quotedPost?.author.did === did) { 529 - yield quotedPost?.author 530 - } 531 - } 532 - } 533 - for (let profile of findAllProfilesInFeedQueryData(queryClient, did)) { 534 - yield profile 535 - } 536 - for (let profile of findAllProfilesInNotifsQueryData(queryClient, did)) { 537 - yield profile 538 - } 539 - for (let profile of findAllProfilesInSearchQueryData(queryClient, did)) { 540 - yield profile 541 - } 542 - for (let profile of findAllProfilesInExploreFeedPreviewsQueryData( 543 - queryClient, 544 - did, 545 - )) { 546 - yield profile 547 - } 548 - } 549 - 550 - function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> { 551 - if (node.type === 'post') { 552 - if (node.parent) { 553 - yield* traverseThread(node.parent) 554 - } 555 - yield node 556 - if (node.replies?.length) { 557 - for (const reply of node.replies) { 558 - yield* traverseThread(reply) 559 - } 560 - } 561 - } 562 - } 563 - 564 - function threadNodeToPlaceholderThread( 565 - node: ThreadNode, 566 - ): ThreadNode | undefined { 567 - if (node.type !== 'post') { 568 - return undefined 569 - } 570 - return { 571 - type: node.type, 572 - _reactKey: node._reactKey, 573 - uri: node.uri, 574 - post: node.post, 575 - record: node.record, 576 - parent: undefined, 577 - replies: undefined, 578 - hasOPLike: undefined, 579 - ctx: { 580 - depth: 0, 581 - isHighlightedPost: true, 582 - hasMore: false, 583 - isParentLoading: !!node.record.reply, 584 - isChildLoading: !!node.post.replyCount, 585 - }, 586 - } 587 - } 588 - 589 - function postViewToPlaceholderThread( 590 - post: AppBskyFeedDefs.PostView, 591 - ): ThreadNode { 592 - return { 593 - type: 'post', 594 - _reactKey: post.uri, 595 - uri: post.uri, 596 - post: post, 597 - record: post.record as AppBskyFeedPost.Record, // validated in notifs 598 - parent: undefined, 599 - replies: undefined, 600 - hasOPLike: undefined, 601 - ctx: { 602 - depth: 0, 603 - isHighlightedPost: true, 604 - hasMore: false, 605 - isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply, 606 - isChildLoading: true, // assume yes (show the spinner) just in case 607 - }, 608 - } 609 - } 610 - 611 - function embedViewRecordToPlaceholderThread( 612 - record: AppBskyEmbedRecord.ViewRecord, 613 - ): ThreadNode { 614 - return { 615 - type: 'post', 616 - _reactKey: record.uri, 617 - uri: record.uri, 618 - post: embedViewRecordToPostView(record), 619 - record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost 620 - parent: undefined, 621 - replies: undefined, 622 - hasOPLike: undefined, 623 - ctx: { 624 - depth: 0, 625 - isHighlightedPost: true, 626 - hasMore: false, 627 - isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply, 628 - isChildLoading: true, // not available, so assume yes (to show the spinner) 629 - }, 630 - } 631 - }
+29 -47
src/state/queries/threadgate/index.ts
··· 1 1 import { 2 - AppBskyFeedDefs, 3 - type AppBskyFeedGetPostThread, 2 + type AppBskyFeedDefs, 4 3 AppBskyFeedThreadgate, 5 4 AtUri, 6 5 type BskyAgent, ··· 8 7 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 9 8 10 9 import {networkRetry, retry} from '#/lib/async/retry' 11 - import {until} from '#/lib/async/until' 12 10 import {STALE} from '#/state/queries' 13 - import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread' 11 + import {useGetPost} from '#/state/queries/post' 14 12 import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' 15 13 import { 16 14 createThreadgateRecord, ··· 18 16 threadgateAllowUISettingToAllowRecordValue, 19 17 threadgateViewToAllowUISetting, 20 18 } from '#/state/queries/threadgate/util' 19 + import {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread' 21 20 import {useAgent} from '#/state/session' 22 21 import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies' 23 22 import * as bsky from '#/types/bsky' ··· 71 70 postUri?: string 72 71 initialData?: AppBskyFeedDefs.ThreadgateView 73 72 } = {}) { 74 - const agent = useAgent() 73 + const getPost = useGetPost() 75 74 76 75 return useQuery({ 77 76 enabled: !!postUri, ··· 79 78 placeholderData: initialData, 80 79 staleTime: STALE.MINUTES.ONE, 81 80 async queryFn() { 82 - return getThreadgateView({ 83 - agent, 84 - postUri: postUri!, 85 - }) 81 + const post = await getPost({uri: postUri!}) 82 + return post.threadgate ?? null 86 83 }, 87 84 }) 88 - } 89 - 90 - export async function getThreadgateView({ 91 - agent, 92 - postUri, 93 - }: { 94 - agent: BskyAgent 95 - postUri: string 96 - }) { 97 - const {data} = await agent.app.bsky.feed.getPostThread({ 98 - uri: postUri!, 99 - depth: 0, 100 - }) 101 - 102 - if (AppBskyFeedDefs.isThreadViewPost(data.thread)) { 103 - return data.thread.post.threadgate ?? null 104 - } 105 - 106 - return null 107 85 } 108 86 109 87 export async function getThreadgateRecord({ ··· 248 226 export function useSetThreadgateAllowMutation() { 249 227 const agent = useAgent() 250 228 const queryClient = useQueryClient() 229 + const getPost = useGetPost() 230 + const updatePostThreadThreadgate = useUpdatePostThreadThreadgateQueryCache() 251 231 252 232 return useMutation({ 253 233 mutationFn: async ({ ··· 272 252 }) 273 253 }, 274 254 async onSuccess(_, {postUri, allow}) { 275 - await until( 255 + const data = await retry<AppBskyFeedDefs.ThreadgateView | undefined>( 276 256 5, // 5 tries 277 - 1e3, // 1s delay between tries 278 - (res: AppBskyFeedGetPostThread.Response) => { 279 - const thread = res.data.thread 280 - if (AppBskyFeedDefs.isThreadViewPost(thread)) { 281 - const fetchedSettings = threadgateViewToAllowUISetting( 282 - thread.post.threadgate, 257 + _e => true, 258 + async () => { 259 + const post = await getPost({uri: postUri}) 260 + const threadgate = post.threadgate 261 + if (!threadgate) { 262 + throw new Error( 263 + `useSetThreadgateAllowMutation: could not fetch threadgate, appview may not be ready yet`, 283 264 ) 284 - return JSON.stringify(fetchedSettings) === JSON.stringify(allow) 285 265 } 286 - return false 287 - }, 288 - () => { 289 - return agent.app.bsky.feed.getPostThread({ 290 - uri: postUri, 291 - depth: 0, 292 - }) 266 + const fetchedSettings = threadgateViewToAllowUISetting(threadgate) 267 + const isReady = 268 + JSON.stringify(fetchedSettings) === JSON.stringify(allow) 269 + if (!isReady) { 270 + throw new Error( 271 + `useSetThreadgateAllowMutation: appview isn't ready yet`, 272 + ) // try again 273 + } 274 + return threadgate 293 275 }, 294 - ) 276 + 1e3, // 1s delay between tries 277 + ).catch(() => {}) 278 + 279 + if (data) updatePostThreadThreadgate(data) 295 280 296 - queryClient.invalidateQueries({ 297 - queryKey: [postThreadQueryKeyRoot], 298 - }) 299 281 queryClient.invalidateQueries({ 300 282 queryKey: [threadgateRecordQueryKeyRoot], 301 283 })
+43
src/state/queries/usePostThread/context.tsx
··· 1 + import {createContext, useContext} from 'react' 2 + 3 + import { 4 + type createPostThreadOtherQueryKey, 5 + type createPostThreadQueryKey, 6 + } from '#/state/queries/usePostThread/types' 7 + 8 + /** 9 + * Contains static metadata about the post thread query, suitable for 10 + * context e.g. query keys and other things that don't update frequently. 11 + * 12 + * Be careful adding things here, as it could cause unnecessary re-renders. 13 + */ 14 + export type PostThreadContextType = { 15 + postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey> 16 + postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey> 17 + } 18 + 19 + const PostThreadContext = createContext<PostThreadContextType | undefined>( 20 + undefined, 21 + ) 22 + 23 + /** 24 + * Use the current {@link PostThreadContext}, if one is available. If not, 25 + * returns `undefined`. 26 + */ 27 + export function usePostThreadContext() { 28 + return useContext(PostThreadContext) 29 + } 30 + 31 + export function PostThreadContextProvider({ 32 + children, 33 + context, 34 + }: { 35 + children: React.ReactNode 36 + context?: PostThreadContextType 37 + }) { 38 + return ( 39 + <PostThreadContext.Provider value={context}> 40 + {children} 41 + </PostThreadContext.Provider> 42 + ) 43 + }
+24 -15
src/state/queries/usePostThread/index.ts
··· 11 11 TREE_VIEW_BELOW_DESKTOP, 12 12 TREE_VIEW_BF, 13 13 } from '#/state/queries/usePostThread/const' 14 + import {type PostThreadContextType} from '#/state/queries/usePostThread/context' 14 15 import { 15 16 createCacheMutator, 16 17 getThreadPlaceholder, ··· 31 32 import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 32 33 import {useBreakpoints} from '#/alf' 33 34 35 + export * from '#/state/queries/usePostThread/context' 36 + export {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread/queryCache' 34 37 export * from '#/state/queries/usePostThread/types' 35 38 36 39 export function usePostThread({anchor}: {anchor?: string}) { ··· 277 280 setOtherItemsVisible, 278 281 ]) 279 282 280 - return useMemo( 281 - () => ({ 283 + return useMemo(() => { 284 + const context: PostThreadContextType = { 285 + postThreadQueryKey, 286 + postThreadOtherQueryKey, 287 + } 288 + return { 289 + context, 282 290 state: { 283 291 /* 284 292 * Copy in any query state that is useful ··· 309 317 setSort, 310 318 setView, 311 319 }, 312 - }), 313 - [ 314 - query, 315 - mutator.insertReplies, 316 - otherItemsVisible, 317 - sort, 318 - view, 319 - setSort, 320 - setView, 321 - threadgate, 322 - items, 323 - ], 324 - ) 320 + } 321 + }, [ 322 + query, 323 + mutator.insertReplies, 324 + otherItemsVisible, 325 + sort, 326 + view, 327 + setSort, 328 + setView, 329 + threadgate, 330 + items, 331 + postThreadQueryKey, 332 + postThreadOtherQueryKey, 333 + ]) 325 334 }
+51 -1
src/state/queries/usePostThread/queryCache.ts
··· 1 + import {useCallback} from 'react' 1 2 import { 2 3 type $Typed, 3 4 type AppBskyActorDefs, ··· 7 8 type AppBskyUnspeccedGetPostThreadV2, 8 9 AtUri, 9 10 } from '@atproto/api' 10 - import {type QueryClient} from '@tanstack/react-query' 11 + import {type QueryClient, useQueryClient} from '@tanstack/react-query' 11 12 12 13 import { 13 14 dangerousGetPostShadow, ··· 18 19 import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' 19 20 import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' 20 21 import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' 22 + import {usePostThreadContext} from '#/state/queries/usePostThread' 21 23 import {getBranch} from '#/state/queries/usePostThread/traversal' 22 24 import { 23 25 type ApiThreadItem, ··· 322 324 } 323 325 } 324 326 } 327 + 328 + export 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 + }
+10 -9
src/view/com/composer/Composer.tsx
··· 44 44 import {useSafeAreaInsets} from 'react-native-safe-area-context' 45 45 import {type ImagePickerAsset} from 'expo-image-picker' 46 46 import { 47 - AppBskyFeedDefs, 48 - type AppBskyFeedGetPostThread, 49 47 AppBskyUnspeccedDefs, 48 + type AppBskyUnspeccedGetPostThreadV2, 50 49 AtUri, 51 50 type BskyAgent, 52 51 type RichText, ··· 549 548 if (initQuote) { 550 549 // We want to wait for the quote count to update before we call `onPost`, which will refetch data 551 550 whenAppViewReady(agent, initQuote.uri, res => { 552 - const quotedThread = res.data.thread 551 + const anchor = res.data.thread.at(0) 553 552 if ( 554 - AppBskyFeedDefs.isThreadViewPost(quotedThread) && 555 - quotedThread.post.quoteCount !== initQuote.quoteCount 553 + AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) && 554 + anchor.value.post.quoteCount !== initQuote.quoteCount 556 555 ) { 557 556 onPost?.(postUri) 558 557 onPostSuccess?.(postSuccessData) ··· 1661 1660 async function whenAppViewReady( 1662 1661 agent: BskyAgent, 1663 1662 uri: string, 1664 - fn: (res: AppBskyFeedGetPostThread.Response) => boolean, 1663 + fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean, 1665 1664 ) { 1666 1665 await until( 1667 1666 5, // 5 tries 1668 1667 1e3, // 1s delay between tries 1669 1668 fn, 1670 1669 () => 1671 - agent.app.bsky.feed.getPostThread({ 1672 - uri, 1673 - depth: 0, 1670 + agent.app.bsky.unspecced.getPostThreadV2({ 1671 + anchor: uri, 1672 + above: false, 1673 + below: 0, 1674 + branchingFactor: 0, 1674 1675 }), 1675 1676 ) 1676 1677 }