From 0b50e9f10995d78b71327bea30d9d7b1a0d10f61 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 13 Oct 2025 18:36:11 -0500 Subject: [PATCH] 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 --- .../dialogs/PostInteractionSettingsDialog.tsx | 19 +- src/screens/Post/PostLikedBy.tsx | 8 +- src/screens/Post/PostQuotes.tsx | 8 +- src/screens/Post/PostRepostedBy.tsx | 8 +- src/screens/PostThread/index.tsx | 10 +- src/state/cache/post-shadow.ts | 6 - src/state/cache/profile-shadow.ts | 2 - src/state/queries/post-thread.ts | 631 ------------------ src/state/queries/threadgate/index.ts | 76 +-- src/state/queries/usePostThread/context.tsx | 43 ++ src/state/queries/usePostThread/index.ts | 39 +- src/state/queries/usePostThread/queryCache.ts | 52 +- src/view/com/composer/Composer.tsx | 19 +- 13 files changed, 191 insertions(+), 730 deletions(-) delete mode 100644 src/state/queries/post-thread.ts create mode 100644 src/state/queries/usePostThread/context.tsx diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx index 01194ef651..5b9fc262dc 100644 --- a/src/components/dialogs/PostInteractionSettingsDialog.tsx +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -13,6 +13,7 @@ import isEqual from 'lodash.isequal' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {useMyListsQuery} from '#/state/queries/my-lists' +import {useGetPost} from '#/state/queries/post' import { createPostgateQueryKey, getPostgateRecord, @@ -25,12 +26,15 @@ import { } from '#/state/queries/postgate/util' import { createThreadgateViewQueryKey, - getThreadgateView, type ThreadgateAllowUISetting, threadgateViewToAllowUISetting, useSetThreadgateAllowMutation, useThreadgateViewQuery, } from '#/state/queries/threadgate' +import { + PostThreadContextProvider, + usePostThreadContext, +} from '#/state/queries/usePostThread' import {useAgent, useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' @@ -133,10 +137,13 @@ export type PostInteractionSettingsDialogProps = { export function PostInteractionSettingsDialog( props: PostInteractionSettingsDialogProps, ) { + const postThreadContext = usePostThreadContext() return ( - + + + ) } @@ -558,6 +565,7 @@ export function usePrefetchPostInteractionSettings({ }) { const queryClient = useQueryClient() const agent = useAgent() + const getPost = useGetPost() return React.useCallback(async () => { try { @@ -570,7 +578,10 @@ export function usePrefetchPostInteractionSettings({ }), queryClient.prefetchQuery({ queryKey: createThreadgateViewQueryKey(rootPostUri), - queryFn: () => getThreadgateView({agent, postUri: rootPostUri}), + queryFn: async () => { + const post = await getPost({uri: rootPostUri}) + return post.threadgate ?? null + }, staleTime: STALE.SECONDS.THIRTY, }), ]) @@ -579,5 +590,5 @@ export function usePrefetchPostInteractionSettings({ safeMessage: e.message, }) } - }, [queryClient, agent, postUri, rootPostUri]) + }, [queryClient, agent, postUri, rootPostUri, getPost]) } diff --git a/src/screens/Post/PostLikedBy.tsx b/src/screens/Post/PostLikedBy.tsx index e1a482452f..b8a2302468 100644 --- a/src/screens/Post/PostLikedBy.tsx +++ b/src/screens/Post/PostLikedBy.tsx @@ -7,7 +7,7 @@ import { type NativeStackScreenProps, } from '#/lib/routes/types' import {makeRecordUri} from '#/lib/strings/url-helpers' -import {usePostThreadQuery} from '#/state/queries/post-thread' +import {usePostQuery} from '#/state/queries/post' import {useSetMinimalShellMode} from '#/state/shell' import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy' import * as Layout from '#/components/Layout' @@ -17,11 +17,11 @@ export const PostLikedByScreen = ({route}: Props) => { const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - const {data: post} = usePostThreadQuery(uri) + const {data: post} = usePostQuery(uri) let likeCount - if (post?.thread.type === 'post') { - likeCount = post.thread.post.likeCount + if (post) { + likeCount = post.likeCount } useFocusEffect( diff --git a/src/screens/Post/PostQuotes.tsx b/src/screens/Post/PostQuotes.tsx index 3fa5d80275..33ebe94f33 100644 --- a/src/screens/Post/PostQuotes.tsx +++ b/src/screens/Post/PostQuotes.tsx @@ -7,7 +7,7 @@ import { type NativeStackScreenProps, } from '#/lib/routes/types' import {makeRecordUri} from '#/lib/strings/url-helpers' -import {usePostThreadQuery} from '#/state/queries/post-thread' +import {usePostQuery} from '#/state/queries/post' import {useSetMinimalShellMode} from '#/state/shell' import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes' import * as Layout from '#/components/Layout' @@ -17,11 +17,11 @@ export const PostQuotesScreen = ({route}: Props) => { const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - const {data: post} = usePostThreadQuery(uri) + const {data: post} = usePostQuery(uri) let quoteCount - if (post?.thread.type === 'post') { - quoteCount = post.thread.post.quoteCount + if (post) { + quoteCount = post.quoteCount } useFocusEffect( diff --git a/src/screens/Post/PostRepostedBy.tsx b/src/screens/Post/PostRepostedBy.tsx index d79321d0a9..37f31ce22b 100644 --- a/src/screens/Post/PostRepostedBy.tsx +++ b/src/screens/Post/PostRepostedBy.tsx @@ -7,7 +7,7 @@ import { type NativeStackScreenProps, } from '#/lib/routes/types' import {makeRecordUri} from '#/lib/strings/url-helpers' -import {usePostThreadQuery} from '#/state/queries/post-thread' +import {usePostQuery} from '#/state/queries/post' import {useSetMinimalShellMode} from '#/state/shell' import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy' import * as Layout from '#/components/Layout' @@ -17,11 +17,11 @@ export const PostRepostedByScreen = ({route}: Props) => { const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const setMinimalShellMode = useSetMinimalShellMode() - const {data: post} = usePostThreadQuery(uri) + const {data: post} = usePostQuery(uri) let quoteCount - if (post?.thread.type === 'post') { - quoteCount = post.thread.post.repostCount + if (post) { + quoteCount = post.repostCount } useFocusEffect( diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 7918f4474c..64a6f0f295 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -7,7 +7,11 @@ import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useFeedFeedback} from '#/state/feed-feedback' import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' -import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread' +import { + PostThreadContextProvider, + type ThreadItem, + usePostThread, +} from '#/state/queries/usePostThread' import {useSession} from '#/state/session' import {type OnPostSuccessData} from '#/state/shell/composer' import {useShellLayout} from '#/state/shell/shell-layout' @@ -492,7 +496,7 @@ export function PostThread({uri}: {uri: string}) { const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined return ( - <> + @@ -575,7 +579,7 @@ export function PostThread({uri}: {uri: string}) { {!gtMobile && canReply && hasSession && ( )} - + ) } diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 312bc6df8d..05a702cd5f 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -12,7 +12,6 @@ import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} f import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' -import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' import {castAsShadow, type Shadow} from './types' @@ -176,11 +175,6 @@ function* findPostsInCache( for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { yield post } - for (let node of findAllPostsInThreadQueryData(queryClient, uri)) { - if (node.type === 'post') { - yield node.post - } - } for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) { yield post } diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 1489e65fd9..168661e0d1 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -16,7 +16,6 @@ import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '#/s import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by' import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes' import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by' -import {findAllProfilesInQueryData as findAllProfilesInPostThreadQueryData} from '#/state/queries/post-thread' import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '#/state/queries/profile' import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '#/state/queries/profile-followers' import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows' @@ -173,7 +172,6 @@ function* findProfilesInCache( yield* findAllProfilesInActorSearchQueryData(queryClient, did) yield* findAllProfilesInListConvosQueryData(queryClient, did) yield* findAllProfilesInFeedsQueryData(queryClient, did) - yield* findAllProfilesInPostThreadQueryData(queryClient, did) yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts deleted file mode 100644 index 551fedc8b4..0000000000 --- a/src/state/queries/post-thread.ts +++ /dev/null @@ -1,631 +0,0 @@ -import { - type AppBskyActorDefs, - type AppBskyEmbedRecord, - AppBskyFeedDefs, - type AppBskyFeedGetPostThread, - AppBskyFeedPost, - AtUri, - moderatePost, - type ModerationDecision, - type ModerationOpts, -} from '@atproto/api' -import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' - -import { - findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData, - findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData, -} from '#/state/queries/explore-feed-previews' -import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' -import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' -import { - findAllPostsInQueryData as findAllPostsInSearchQueryData, - findAllProfilesInQueryData as findAllProfilesInSearchQueryData, -} from '#/state/queries/search-posts' -import {useAgent} from '#/state/session' -import * as bsky from '#/types/bsky' -import { - findAllPostsInQueryData as findAllPostsInNotifsQueryData, - findAllProfilesInQueryData as findAllProfilesInNotifsQueryData, -} from './notifications/feed' -import { - findAllPostsInQueryData as findAllPostsInFeedQueryData, - findAllProfilesInQueryData as findAllProfilesInFeedQueryData, -} from './post-feed' -import { - didOrHandleUriMatches, - embedViewRecordToPostView, - getEmbeddedPost, -} from './util' - -const REPLY_TREE_DEPTH = 10 -export const RQKEY_ROOT = 'post-thread' -export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] -type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] - -export interface ThreadCtx { - depth: number - isHighlightedPost?: boolean - hasMore?: boolean - isParentLoading?: boolean - isChildLoading?: boolean - isSelfThread?: boolean - hasMoreSelfThread?: boolean -} - -export type ThreadPost = { - type: 'post' - _reactKey: string - uri: string - post: AppBskyFeedDefs.PostView - record: AppBskyFeedPost.Record - parent: ThreadNode | undefined - replies: ThreadNode[] | undefined - hasOPLike: boolean | undefined - ctx: ThreadCtx -} - -export type ThreadNotFound = { - type: 'not-found' - _reactKey: string - uri: string - ctx: ThreadCtx -} - -export type ThreadBlocked = { - type: 'blocked' - _reactKey: string - uri: string - ctx: ThreadCtx -} - -export type ThreadUnknown = { - type: 'unknown' - uri: string -} - -export type ThreadNode = - | ThreadPost - | ThreadNotFound - | ThreadBlocked - | ThreadUnknown - -export type ThreadModerationCache = WeakMap - -export type PostThreadQueryData = { - thread: ThreadNode - threadgate?: AppBskyFeedDefs.ThreadgateView -} - -export function usePostThreadQuery(uri: string | undefined) { - const queryClient = useQueryClient() - const agent = useAgent() - return useQuery({ - gcTime: 0, - queryKey: RQKEY(uri || ''), - async queryFn() { - const res = await agent.getPostThread({ - uri: uri!, - depth: REPLY_TREE_DEPTH, - }) - if (res.success) { - const thread = responseToThreadNodes(res.data.thread) - annotateSelfThread(thread) - return { - thread, - threadgate: res.data.threadgate as - | AppBskyFeedDefs.ThreadgateView - | undefined, - } - } - return {thread: {type: 'unknown', uri: uri!}} - }, - enabled: !!uri, - placeholderData: () => { - if (!uri) return - const post = findPostInQueryData(queryClient, uri) - if (post) { - return {thread: post} - } - return undefined - }, - }) -} - -export function fillThreadModerationCache( - cache: ThreadModerationCache, - node: ThreadNode, - moderationOpts: ModerationOpts, -) { - if (node.type === 'post') { - cache.set(node, moderatePost(node.post, moderationOpts)) - if (node.parent) { - fillThreadModerationCache(cache, node.parent, moderationOpts) - } - if (node.replies) { - for (const reply of node.replies) { - fillThreadModerationCache(cache, reply, moderationOpts) - } - } - } -} - -export function sortThread( - node: ThreadNode, - opts: UsePreferencesQueryResponse['threadViewPrefs'], - modCache: ThreadModerationCache, - currentDid: string | undefined, - justPostedUris: Set, - threadgateRecordHiddenReplies: Set, - fetchedAtCache: Map, - fetchedAt: number, - randomCache: Map, -): ThreadNode { - if (node.type !== 'post') { - return node - } - if (node.replies) { - node.replies.sort((a: ThreadNode, b: ThreadNode) => { - if (a.type !== 'post') { - return 1 - } - if (b.type !== 'post') { - return -1 - } - - if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) { - const aIsJustPosted = - a.post.author.did === currentDid && justPostedUris.has(a.post.uri) - const bIsJustPosted = - b.post.author.did === currentDid && justPostedUris.has(b.post.uri) - if (aIsJustPosted && bIsJustPosted) { - return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest - } else if (aIsJustPosted) { - return -1 // reply while onscreen - } else if (bIsJustPosted) { - return 1 // reply while onscreen - } - } - - const aIsByOp = a.post.author.did === node.post?.author.did - const bIsByOp = b.post.author.did === node.post?.author.did - if (aIsByOp && bIsByOp) { - return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest - } else if (aIsByOp) { - return -1 // op's own reply - } else if (bIsByOp) { - return 1 // op's own reply - } - - const aIsBySelf = a.post.author.did === currentDid - const bIsBySelf = b.post.author.did === currentDid - if (aIsBySelf && bIsBySelf) { - return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest - } else if (aIsBySelf) { - return -1 // current account's reply - } else if (bIsBySelf) { - return 1 // current account's reply - } - - const aHidden = threadgateRecordHiddenReplies.has(a.uri) - const bHidden = threadgateRecordHiddenReplies.has(b.uri) - if (aHidden && !aIsBySelf && !bHidden) { - return 1 - } else if (bHidden && !bIsBySelf && !aHidden) { - return -1 - } - - const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur) - const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur) - if (aBlur !== bBlur) { - if (aBlur) { - return 1 - } - if (bBlur) { - return -1 - } - } - - const aPin = Boolean(a.record.text.trim() === '📌') - const bPin = Boolean(b.record.text.trim() === '📌') - if (aPin !== bPin) { - if (aPin) { - return 1 - } - if (bPin) { - return -1 - } - } - - if (opts.prioritizeFollowedUsers) { - const af = a.post.author.viewer?.following - const bf = b.post.author.viewer?.following - if (af && !bf) { - return -1 - } else if (!af && bf) { - return 1 - } - } - - // Split items from different fetches into separate generations. - let aFetchedAt = fetchedAtCache.get(a.uri) - if (aFetchedAt === undefined) { - fetchedAtCache.set(a.uri, fetchedAt) - aFetchedAt = fetchedAt - } - let bFetchedAt = fetchedAtCache.get(b.uri) - if (bFetchedAt === undefined) { - fetchedAtCache.set(b.uri, fetchedAt) - bFetchedAt = fetchedAt - } - - if (aFetchedAt !== bFetchedAt) { - return aFetchedAt - bFetchedAt // older fetches first - } else if (opts.sort === 'hotness') { - const aHotness = getHotness(a, aFetchedAt) - const bHotness = getHotness(b, bFetchedAt /* same as aFetchedAt */) - return bHotness - aHotness - } else if (opts.sort === 'oldest') { - return a.post.indexedAt.localeCompare(b.post.indexedAt) - } else if (opts.sort === 'newest') { - return b.post.indexedAt.localeCompare(a.post.indexedAt) - } else if (opts.sort === 'most-likes') { - if (a.post.likeCount === b.post.likeCount) { - return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest - } else { - return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes - } - } else if (opts.sort === 'random') { - let aRandomScore = randomCache.get(a.uri) - if (aRandomScore === undefined) { - aRandomScore = Math.random() - randomCache.set(a.uri, aRandomScore) - } - let bRandomScore = randomCache.get(b.uri) - if (bRandomScore === undefined) { - bRandomScore = Math.random() - randomCache.set(b.uri, bRandomScore) - } - // this is vaguely criminal but we can get away with it - return aRandomScore - bRandomScore - } else { - return b.post.indexedAt.localeCompare(a.post.indexedAt) - } - }) - node.replies.forEach(reply => - sortThread( - reply, - opts, - modCache, - currentDid, - justPostedUris, - threadgateRecordHiddenReplies, - fetchedAtCache, - fetchedAt, - randomCache, - ), - ) - } - return node -} - -// internal methods -// = - -// Inspired by https://join-lemmy.org/docs/contributors/07-ranking-algo.html -// We want to give recent comments a real chance (and not bury them deep below the fold) -// while also surfacing well-liked comments from the past. In the future, we can explore -// something more sophisticated, but we don't have much data on the client right now. -function getHotness(threadPost: ThreadPost, fetchedAt: number) { - const {post, hasOPLike} = threadPost - const hoursAgo = Math.max( - 0, - (new Date(fetchedAt).getTime() - new Date(post.indexedAt).getTime()) / - (1000 * 60 * 60), - ) - const likeCount = post.likeCount ?? 0 - const likeOrder = Math.log(3 + likeCount) * (hasOPLike ? 1.45 : 1.0) - const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount)) - const opLikeBoost = hasOPLike ? 0.8 : 1.0 - const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent * opLikeBoost) - return likeOrder / timePenalty -} - -function responseToThreadNodes( - node: ThreadViewNode, - depth = 0, - direction: 'up' | 'down' | 'start' = 'start', -): ThreadNode { - if ( - AppBskyFeedDefs.isThreadViewPost(node) && - bsky.dangerousIsType( - node.post.record, - AppBskyFeedPost.isRecord, - ) - ) { - const post = node.post - // These should normally be present. They're missing only for - // posts that were *just* created. Ideally, the backend would - // know to return zeros. Fill them in manually to compensate. - post.replyCount ??= 0 - post.likeCount ??= 0 - post.repostCount ??= 0 - return { - type: 'post', - _reactKey: node.post.uri, - uri: node.post.uri, - post: post, - record: node.post.record, - parent: - node.parent && direction !== 'down' - ? responseToThreadNodes(node.parent, depth - 1, 'up') - : undefined, - replies: - node.replies?.length && direction !== 'up' - ? node.replies - .map(reply => responseToThreadNodes(reply, depth + 1, 'down')) - // do not show blocked posts in replies - .filter(node => node.type !== 'blocked') - : undefined, - hasOPLike: Boolean(node?.threadContext?.rootAuthorLike), - ctx: { - depth, - isHighlightedPost: depth === 0, - hasMore: - direction === 'down' && !node.replies?.length && !!post.replyCount, - isSelfThread: false, // populated `annotateSelfThread` - hasMoreSelfThread: false, // populated in `annotateSelfThread` - }, - } - } else if (AppBskyFeedDefs.isBlockedPost(node)) { - return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}} - } else if (AppBskyFeedDefs.isNotFoundPost(node)) { - return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}} - } else { - return {type: 'unknown', uri: ''} - } -} - -function annotateSelfThread(thread: ThreadNode) { - if (thread.type !== 'post') { - return - } - const selfThreadNodes: ThreadPost[] = [thread] - - let parent: ThreadNode | undefined = thread.parent - while (parent) { - if ( - parent.type !== 'post' || - parent.post.author.did !== thread.post.author.did - ) { - // not a self-thread - return - } - selfThreadNodes.unshift(parent) - parent = parent.parent - } - - let node = thread - for (let i = 0; i < 10; i++) { - const reply = node.replies?.find( - r => r.type === 'post' && r.post.author.did === thread.post.author.did, - ) - if (reply?.type !== 'post') { - break - } - selfThreadNodes.push(reply) - node = reply - } - - if (selfThreadNodes.length > 1) { - for (const selfThreadNode of selfThreadNodes) { - selfThreadNode.ctx.isSelfThread = true - } - const last = selfThreadNodes[selfThreadNodes.length - 1] - if ( - last && - last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth - last.post.replyCount && // has replies - !last.replies?.length // replies were not hydrated - ) { - last.ctx.hasMoreSelfThread = true - } - } -} - -function findPostInQueryData( - queryClient: QueryClient, - uri: string, -): ThreadNode | void { - let partial - for (let item of findAllPostsInQueryData(queryClient, uri)) { - if (item.type === 'post') { - // Currently, the backend doesn't send full post info in some cases - // (for example, for quoted posts). We use missing `likeCount` - // as a way to detect that. In the future, we should fix this on - // the backend, which will let us always stop on the first result. - const hasAllInfo = item.post.likeCount != null - if (hasAllInfo) { - return item - } else { - partial = item - // Keep searching, we might still find a full post in the cache. - } - } - } - return partial -} - -export function* findAllPostsInQueryData( - queryClient: QueryClient, - uri: string, -): Generator { - const atUri = new AtUri(uri) - - const queryDatas = queryClient.getQueriesData({ - queryKey: [RQKEY_ROOT], - }) - for (const [_queryKey, queryData] of queryDatas) { - if (!queryData) { - continue - } - const {thread} = queryData - for (const item of traverseThread(thread)) { - if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) { - const placeholder = threadNodeToPlaceholderThread(item) - if (placeholder) { - yield placeholder - } - } - const quotedPost = - item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined - if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { - yield embedViewRecordToPlaceholderThread(quotedPost) - } - } - } - for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { - // Check notifications first. If you have a post in notifications, - // it's often due to a like or a repost, and we want to prioritize - // a post object with >0 likes/reposts over a stale version with no - // metrics in order to avoid a notification->post scroll jump. - yield postViewToPlaceholderThread(post) - } - for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { - yield postViewToPlaceholderThread(post) - } - for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { - yield postViewToPlaceholderThread(post) - } - for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { - yield postViewToPlaceholderThread(post) - } - for (let post of findAllPostsInExploreFeedPreviewsQueryData( - queryClient, - uri, - )) { - yield postViewToPlaceholderThread(post) - } -} - -export function* findAllProfilesInQueryData( - queryClient: QueryClient, - did: string, -): Generator { - const queryDatas = queryClient.getQueriesData({ - queryKey: [RQKEY_ROOT], - }) - for (const [_queryKey, queryData] of queryDatas) { - if (!queryData) { - continue - } - const {thread} = queryData - for (const item of traverseThread(thread)) { - if (item.type === 'post' && item.post.author.did === did) { - yield item.post.author - } - const quotedPost = - item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined - if (quotedPost?.author.did === did) { - yield quotedPost?.author - } - } - } - for (let profile of findAllProfilesInFeedQueryData(queryClient, did)) { - yield profile - } - for (let profile of findAllProfilesInNotifsQueryData(queryClient, did)) { - yield profile - } - for (let profile of findAllProfilesInSearchQueryData(queryClient, did)) { - yield profile - } - for (let profile of findAllProfilesInExploreFeedPreviewsQueryData( - queryClient, - did, - )) { - yield profile - } -} - -function* traverseThread(node: ThreadNode): Generator { - if (node.type === 'post') { - if (node.parent) { - yield* traverseThread(node.parent) - } - yield node - if (node.replies?.length) { - for (const reply of node.replies) { - yield* traverseThread(reply) - } - } - } -} - -function threadNodeToPlaceholderThread( - node: ThreadNode, -): ThreadNode | undefined { - if (node.type !== 'post') { - return undefined - } - return { - type: node.type, - _reactKey: node._reactKey, - uri: node.uri, - post: node.post, - record: node.record, - parent: undefined, - replies: undefined, - hasOPLike: undefined, - ctx: { - depth: 0, - isHighlightedPost: true, - hasMore: false, - isParentLoading: !!node.record.reply, - isChildLoading: !!node.post.replyCount, - }, - } -} - -function postViewToPlaceholderThread( - post: AppBskyFeedDefs.PostView, -): ThreadNode { - return { - type: 'post', - _reactKey: post.uri, - uri: post.uri, - post: post, - record: post.record as AppBskyFeedPost.Record, // validated in notifs - parent: undefined, - replies: undefined, - hasOPLike: undefined, - ctx: { - depth: 0, - isHighlightedPost: true, - hasMore: false, - isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply, - isChildLoading: true, // assume yes (show the spinner) just in case - }, - } -} - -function embedViewRecordToPlaceholderThread( - record: AppBskyEmbedRecord.ViewRecord, -): ThreadNode { - return { - type: 'post', - _reactKey: record.uri, - uri: record.uri, - post: embedViewRecordToPostView(record), - record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost - parent: undefined, - replies: undefined, - hasOPLike: undefined, - ctx: { - depth: 0, - isHighlightedPost: true, - hasMore: false, - isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply, - isChildLoading: true, // not available, so assume yes (to show the spinner) - }, - } -} diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts index 305acc5d05..5a34eb3104 100644 --- a/src/state/queries/threadgate/index.ts +++ b/src/state/queries/threadgate/index.ts @@ -1,6 +1,5 @@ import { - AppBskyFeedDefs, - type AppBskyFeedGetPostThread, + type AppBskyFeedDefs, AppBskyFeedThreadgate, AtUri, type BskyAgent, @@ -8,9 +7,8 @@ import { import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {networkRetry, retry} from '#/lib/async/retry' -import {until} from '#/lib/async/until' import {STALE} from '#/state/queries' -import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread' +import {useGetPost} from '#/state/queries/post' import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' import { createThreadgateRecord, @@ -18,6 +16,7 @@ import { threadgateAllowUISettingToAllowRecordValue, threadgateViewToAllowUISetting, } from '#/state/queries/threadgate/util' +import {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread' import {useAgent} from '#/state/session' import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies' import * as bsky from '#/types/bsky' @@ -71,7 +70,7 @@ export function useThreadgateViewQuery({ postUri?: string initialData?: AppBskyFeedDefs.ThreadgateView } = {}) { - const agent = useAgent() + const getPost = useGetPost() return useQuery({ enabled: !!postUri, @@ -79,33 +78,12 @@ export function useThreadgateViewQuery({ placeholderData: initialData, staleTime: STALE.MINUTES.ONE, async queryFn() { - return getThreadgateView({ - agent, - postUri: postUri!, - }) + const post = await getPost({uri: postUri!}) + return post.threadgate ?? null }, }) } -export async function getThreadgateView({ - agent, - postUri, -}: { - agent: BskyAgent - postUri: string -}) { - const {data} = await agent.app.bsky.feed.getPostThread({ - uri: postUri!, - depth: 0, - }) - - if (AppBskyFeedDefs.isThreadViewPost(data.thread)) { - return data.thread.post.threadgate ?? null - } - - return null -} - export async function getThreadgateRecord({ agent, postUri, @@ -248,6 +226,8 @@ export async function updateThreadgateAllow({ export function useSetThreadgateAllowMutation() { const agent = useAgent() const queryClient = useQueryClient() + const getPost = useGetPost() + const updatePostThreadThreadgate = useUpdatePostThreadThreadgateQueryCache() return useMutation({ mutationFn: async ({ @@ -272,30 +252,32 @@ export function useSetThreadgateAllowMutation() { }) }, async onSuccess(_, {postUri, allow}) { - await until( + const data = await retry( 5, // 5 tries - 1e3, // 1s delay between tries - (res: AppBskyFeedGetPostThread.Response) => { - const thread = res.data.thread - if (AppBskyFeedDefs.isThreadViewPost(thread)) { - const fetchedSettings = threadgateViewToAllowUISetting( - thread.post.threadgate, + _e => true, + async () => { + const post = await getPost({uri: postUri}) + const threadgate = post.threadgate + if (!threadgate) { + throw new Error( + `useSetThreadgateAllowMutation: could not fetch threadgate, appview may not be ready yet`, ) - return JSON.stringify(fetchedSettings) === JSON.stringify(allow) } - return false + const fetchedSettings = threadgateViewToAllowUISetting(threadgate) + const isReady = + JSON.stringify(fetchedSettings) === JSON.stringify(allow) + if (!isReady) { + throw new Error( + `useSetThreadgateAllowMutation: appview isn't ready yet`, + ) // try again + } + return threadgate }, - () => { - return agent.app.bsky.feed.getPostThread({ - uri: postUri, - depth: 0, - }) - }, - ) + 1e3, // 1s delay between tries + ).catch(() => {}) + + if (data) updatePostThreadThreadgate(data) - queryClient.invalidateQueries({ - queryKey: [postThreadQueryKeyRoot], - }) queryClient.invalidateQueries({ queryKey: [threadgateRecordQueryKeyRoot], }) diff --git a/src/state/queries/usePostThread/context.tsx b/src/state/queries/usePostThread/context.tsx new file mode 100644 index 0000000000..f825965416 --- /dev/null +++ b/src/state/queries/usePostThread/context.tsx @@ -0,0 +1,43 @@ +import {createContext, useContext} from 'react' + +import { + type createPostThreadOtherQueryKey, + type createPostThreadQueryKey, +} from '#/state/queries/usePostThread/types' + +/** + * Contains static metadata about the post thread query, suitable for + * context e.g. query keys and other things that don't update frequently. + * + * Be careful adding things here, as it could cause unnecessary re-renders. + */ +export type PostThreadContextType = { + postThreadQueryKey: ReturnType + postThreadOtherQueryKey: ReturnType +} + +const PostThreadContext = createContext( + undefined, +) + +/** + * Use the current {@link PostThreadContext}, if one is available. If not, + * returns `undefined`. + */ +export function usePostThreadContext() { + return useContext(PostThreadContext) +} + +export function PostThreadContextProvider({ + children, + context, +}: { + children: React.ReactNode + context?: PostThreadContextType +}) { + return ( + + {children} + + ) +} diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts index 8495df04c8..e00250f740 100644 --- a/src/state/queries/usePostThread/index.ts +++ b/src/state/queries/usePostThread/index.ts @@ -11,6 +11,7 @@ import { TREE_VIEW_BELOW_DESKTOP, TREE_VIEW_BF, } from '#/state/queries/usePostThread/const' +import {type PostThreadContextType} from '#/state/queries/usePostThread/context' import { createCacheMutator, getThreadPlaceholder, @@ -31,6 +32,8 @@ import {useAgent, useSession} from '#/state/session' import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {useBreakpoints} from '#/alf' +export * from '#/state/queries/usePostThread/context' +export {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread/queryCache' export * from '#/state/queries/usePostThread/types' export function usePostThread({anchor}: {anchor?: string}) { @@ -277,8 +280,13 @@ export function usePostThread({anchor}: {anchor?: string}) { setOtherItemsVisible, ]) - return useMemo( - () => ({ + return useMemo(() => { + const context: PostThreadContextType = { + postThreadQueryKey, + postThreadOtherQueryKey, + } + return { + context, state: { /* * Copy in any query state that is useful @@ -309,17 +317,18 @@ export function usePostThread({anchor}: {anchor?: string}) { setSort, setView, }, - }), - [ - query, - mutator.insertReplies, - otherItemsVisible, - sort, - view, - setSort, - setView, - threadgate, - items, - ], - ) + } + }, [ + query, + mutator.insertReplies, + otherItemsVisible, + sort, + view, + setSort, + setView, + threadgate, + items, + postThreadQueryKey, + postThreadOtherQueryKey, + ]) } diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts index 5e27ebb875..5f9f263280 100644 --- a/src/state/queries/usePostThread/queryCache.ts +++ b/src/state/queries/usePostThread/queryCache.ts @@ -1,3 +1,4 @@ +import {useCallback} from 'react' import { type $Typed, type AppBskyActorDefs, @@ -7,7 +8,7 @@ import { type AppBskyUnspeccedGetPostThreadV2, AtUri, } from '@atproto/api' -import {type QueryClient} from '@tanstack/react-query' +import {type QueryClient, useQueryClient} from '@tanstack/react-query' import { dangerousGetPostShadow, @@ -18,6 +19,7 @@ import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/ import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' +import {usePostThreadContext} from '#/state/queries/usePostThread' import {getBranch} from '#/state/queries/usePostThread/traversal' import { type ApiThreadItem, @@ -322,3 +324,51 @@ export function* findAllProfilesInQueryData( } } } + +export function useUpdatePostThreadThreadgateQueryCache() { + const qc = useQueryClient() + const context = usePostThreadContext() + + return useCallback( + (threadgate: AppBskyFeedDefs.ThreadgateView) => { + if (!context) return + + function mutator(thread: ApiThreadItem[]): T[] { + for (let i = 0; i < thread.length; i++) { + const item = thread[i] + + if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) continue + + if (item.depth === 0) { + thread.splice(i, 1, { + ...item, + value: { + ...item.value, + post: { + ...item.value.post, + threadgate, + }, + }, + }) + } + } + + return thread as T[] + } + + qc.setQueryData( + context.postThreadQueryKey, + data => { + if (!data) return + return { + ...data, + thread: mutator([ + ...data.thread, + ]), + } + }, + ) + }, + [qc, context], + ) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 8cbb2d37ba..305790cb18 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -44,9 +44,8 @@ import Animated, { import {useSafeAreaInsets} from 'react-native-safe-area-context' import {type ImagePickerAsset} from 'expo-image-picker' import { - AppBskyFeedDefs, - type AppBskyFeedGetPostThread, AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, AtUri, type BskyAgent, type RichText, @@ -549,10 +548,10 @@ export const ComposePost = ({ if (initQuote) { // We want to wait for the quote count to update before we call `onPost`, which will refetch data whenAppViewReady(agent, initQuote.uri, res => { - const quotedThread = res.data.thread + const anchor = res.data.thread.at(0) if ( - AppBskyFeedDefs.isThreadViewPost(quotedThread) && - quotedThread.post.quoteCount !== initQuote.quoteCount + AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) && + anchor.value.post.quoteCount !== initQuote.quoteCount ) { onPost?.(postUri) onPostSuccess?.(postSuccessData) @@ -1661,16 +1660,18 @@ function useKeyboardVerticalOffset() { async function whenAppViewReady( agent: BskyAgent, uri: string, - fn: (res: AppBskyFeedGetPostThread.Response) => boolean, + fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean, ) { await until( 5, // 5 tries 1e3, // 1s delay between tries fn, () => - agent.app.bsky.feed.getPostThread({ - uri, - depth: 0, + agent.app.bsky.unspecced.getPostThreadV2({ + anchor: uri, + above: false, + below: 0, + branchingFactor: 0, }), ) }