···12import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
13import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
14import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
15-import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread'
16import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
17import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
18import {castAsShadow, type Shadow} from './types'
···175 }
176 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
177 yield post
178- }
179- for (let node of findAllPostsInThreadQueryData(queryClient, uri)) {
180- if (node.type === 'post') {
181- yield node.post
182- }
183 }
184 for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) {
185 yield post
···12import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
13import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
14import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
015import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
16import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
17import {castAsShadow, type Shadow} from './types'
···174 }
175 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
176 yield post
00000177 }
178 for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) {
179 yield post
-2
src/state/cache/profile-shadow.ts
···16import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by'
17import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes'
18import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by'
19-import {findAllProfilesInQueryData as findAllProfilesInPostThreadQueryData} from '#/state/queries/post-thread'
20import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '#/state/queries/profile'
21import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '#/state/queries/profile-followers'
22import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
···175 yield* findAllProfilesInActorSearchQueryData(queryClient, did)
176 yield* findAllProfilesInListConvosQueryData(queryClient, did)
177 yield* findAllProfilesInFeedsQueryData(queryClient, did)
178- yield* findAllProfilesInPostThreadQueryData(queryClient, did)
179 yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
180 yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
181 yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
···16import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by'
17import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes'
18import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by'
019import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '#/state/queries/profile'
20import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '#/state/queries/profile-followers'
21import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
···174 yield* findAllProfilesInActorSearchQueryData(queryClient, did)
175 yield* findAllProfilesInListConvosQueryData(queryClient, did)
176 yield* findAllProfilesInFeedsQueryData(queryClient, did)
0177 yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
178 yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
179 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-}
···1import {
2+ type AppBskyFeedDefs,
03 AppBskyFeedThreadgate,
4 AtUri,
5 type BskyAgent,
···7import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
89import {networkRetry, retry} from '#/lib/async/retry'
010import {STALE} from '#/state/queries'
11+import {useGetPost} from '#/state/queries/post'
12import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
13import {
14 createThreadgateRecord,
···16 threadgateAllowUISettingToAllowRecordValue,
17 threadgateViewToAllowUISetting,
18} from '#/state/queries/threadgate/util'
19+import {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread'
20import {useAgent} from '#/state/session'
21import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies'
22import * as bsky from '#/types/bsky'
···70 postUri?: string
71 initialData?: AppBskyFeedDefs.ThreadgateView
72} = {}) {
73+ const getPost = useGetPost()
7475 return useQuery({
76 enabled: !!postUri,
···78 placeholderData: initialData,
79 staleTime: STALE.MINUTES.ONE,
80 async queryFn() {
81+ const post = await getPost({uri: postUri!})
82+ return post.threadgate ?? null
0083 },
84 })
000000000000000000085}
8687export async function getThreadgateRecord({
···226export function useSetThreadgateAllowMutation() {
227 const agent = useAgent()
228 const queryClient = useQueryClient()
229+ const getPost = useGetPost()
230+ const updatePostThreadThreadgate = useUpdatePostThreadThreadgateQueryCache()
231232 return useMutation({
233 mutationFn: async ({
···252 })
253 },
254 async onSuccess(_, {postUri, allow}) {
255+ const data = await retry<AppBskyFeedDefs.ThreadgateView | undefined>(
256 5, // 5 tries
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`,
264 )
0265 }
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
275 },
276+ 1e3, // 1s delay between tries
277+ ).catch(() => {})
278+279+ if (data) updatePostThreadThreadgate(data)
280000281 queryClient.invalidateQueries({
282 queryKey: [threadgateRecordQueryKeyRoot],
283 })
+43
src/state/queries/usePostThread/context.tsx
···0000000000000000000000000000000000000000000
···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 TREE_VIEW_BELOW_DESKTOP,
12 TREE_VIEW_BF,
13} from '#/state/queries/usePostThread/const'
014import {
15 createCacheMutator,
16 getThreadPlaceholder,
···31import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
32import {useBreakpoints} from '#/alf'
330034export * from '#/state/queries/usePostThread/types'
3536export function usePostThread({anchor}: {anchor?: string}) {
···277 setOtherItemsVisible,
278 ])
279280- return useMemo(
281- () => ({
00000282 state: {
283 /*
284 * Copy in any query state that is useful
···309 setSort,
310 setView,
311 },
312- }),
313- [
314- query,
315- mutator.insertReplies,
316- otherItemsVisible,
317- sort,
318- view,
319- setSort,
320- setView,
321- threadgate,
322- items,
323- ],
324- )
0325}
···11 TREE_VIEW_BELOW_DESKTOP,
12 TREE_VIEW_BF,
13} from '#/state/queries/usePostThread/const'
14+import {type PostThreadContextType} from '#/state/queries/usePostThread/context'
15import {
16 createCacheMutator,
17 getThreadPlaceholder,
···32import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
33import {useBreakpoints} from '#/alf'
3435+export * from '#/state/queries/usePostThread/context'
36+export {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread/queryCache'
37export * from '#/state/queries/usePostThread/types'
3839export function usePostThread({anchor}: {anchor?: string}) {
···280 setOtherItemsVisible,
281 ])
282283+ return useMemo(() => {
284+ const context: PostThreadContextType = {
285+ postThreadQueryKey,
286+ postThreadOtherQueryKey,
287+ }
288+ return {
289+ context,
290 state: {
291 /*
292 * Copy in any query state that is useful
···317 setSort,
318 setView,
319 },
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+ ])
334}
+51-1
src/state/queries/usePostThread/queryCache.ts
···01import {
2 type $Typed,
3 type AppBskyActorDefs,
···7 type AppBskyUnspeccedGetPostThreadV2,
8 AtUri,
9} from '@atproto/api'
10-import {type QueryClient} from '@tanstack/react-query'
1112import {
13 dangerousGetPostShadow,
···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'
021import {getBranch} from '#/state/queries/usePostThread/traversal'
22import {
23 type ApiThreadItem,
···322 }
323 }
324}
000000000000000000000000000000000000000000000000
···1+import {useCallback} from 'react'
2import {
3 type $Typed,
4 type AppBskyActorDefs,
···8 type AppBskyUnspeccedGetPostThreadV2,
9 AtUri,
10} from '@atproto/api'
11+import {type QueryClient, useQueryClient} from '@tanstack/react-query'
1213import {
14 dangerousGetPostShadow,
···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'
22+import {usePostThreadContext} from '#/state/queries/usePostThread'
23import {getBranch} from '#/state/queries/usePostThread/traversal'
24import {
25 type ApiThreadItem,
···324 }
325 }
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
···44import {useSafeAreaInsets} from 'react-native-safe-area-context'
45import {type ImagePickerAsset} from 'expo-image-picker'
46import {
47- AppBskyFeedDefs,
48- type AppBskyFeedGetPostThread,
49 AppBskyUnspeccedDefs,
050 AtUri,
51 type BskyAgent,
52 type RichText,
···549 if (initQuote) {
550 // We want to wait for the quote count to update before we call `onPost`, which will refetch data
551 whenAppViewReady(agent, initQuote.uri, res => {
552- const quotedThread = res.data.thread
553 if (
554- AppBskyFeedDefs.isThreadViewPost(quotedThread) &&
555- quotedThread.post.quoteCount !== initQuote.quoteCount
556 ) {
557 onPost?.(postUri)
558 onPostSuccess?.(postSuccessData)
···1661async function whenAppViewReady(
1662 agent: BskyAgent,
1663 uri: string,
1664- fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
1665) {
1666 await until(
1667 5, // 5 tries
1668 1e3, // 1s delay between tries
1669 fn,
1670 () =>
1671- agent.app.bsky.feed.getPostThread({
1672- uri,
1673- depth: 0,
001674 }),
1675 )
1676}
···44import {useSafeAreaInsets} from 'react-native-safe-area-context'
45import {type ImagePickerAsset} from 'expo-image-picker'
46import {
0047 AppBskyUnspeccedDefs,
48+ type AppBskyUnspeccedGetPostThreadV2,
49 AtUri,
50 type BskyAgent,
51 type RichText,
···548 if (initQuote) {
549 // We want to wait for the quote count to update before we call `onPost`, which will refetch data
550 whenAppViewReady(agent, initQuote.uri, res => {
551+ const anchor = res.data.thread.at(0)
552 if (
553+ AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) &&
554+ anchor.value.post.quoteCount !== initQuote.quoteCount
555 ) {
556 onPost?.(postUri)
557 onPostSuccess?.(postSuccessData)
···1660async function whenAppViewReady(
1661 agent: BskyAgent,
1662 uri: string,
1663+ fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean,
1664) {
1665 await until(
1666 5, // 5 tries
1667 1e3, // 1s delay between tries
1668 fn,
1669 () =>
1670+ agent.app.bsky.unspecced.getPostThreadV2({
1671+ anchor: uri,
1672+ above: false,
1673+ below: 0,
1674+ branchingFactor: 0,
1675 }),
1676 )
1677}