Compare commits

...

6 Commits

Author SHA1 Message Date
Eric Bailey 2f2eeaffd0 Revert ext change 2025-10-10 16:44:49 -05:00
Eric Bailey 330580bf82 Missing import 2025-10-10 16:43:44 -05:00
Eric Bailey f240730db4 Replace in initQuote handling, which isn't even used rn... 2025-10-10 16:42:09 -05:00
Eric Bailey 28a2365e0c Replace getPostThread in threadgate query 2025-10-10 16:41:40 -05:00
Eric Bailey c8ded6291f Add PostThreadContext, cache mutator for threadgates on threads, pipe it through 2025-10-10 16:20:42 -05:00
Eric Bailey 761fa0bde9 Remove remaining usages of old post thread query 2025-10-10 11:07:31 -05:00
13 changed files with 191 additions and 730 deletions
@@ -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 (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<PostInteractionSettingsDialogControlledInner {...props} />
<PostThreadContextProvider context={postThreadContext}>
<PostInteractionSettingsDialogControlledInner {...props} />
</PostThreadContextProvider>
</Dialog.Outer>
)
}
@@ -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])
}
+4 -4
View File
@@ -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(
+4 -4
View File
@@ -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(
+4 -4
View File
@@ -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(
+7 -3
View File
@@ -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'
@@ -495,7 +499,7 @@ export function PostThread({uri}: {uri: string}) {
const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
return (
<>
<PostThreadContextProvider context={thread.context}>
<Layout.Header.Outer headerRef={headerRef}>
<Layout.Header.BackButton />
<Layout.Header.Content>
@@ -578,7 +582,7 @@ export function PostThread({uri}: {uri: string}) {
{!gtMobile && canReply && hasSession && (
<MobileComposePrompt onPressReply={onReplyToAnchor} />
)}
</>
</PostThreadContextProvider>
)
}
-6
View File
@@ -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
}
-2
View File
@@ -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)
-631
View File
@@ -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<ThreadNode, ModerationDecision>
export type PostThreadQueryData = {
thread: ThreadNode
threadgate?: AppBskyFeedDefs.ThreadgateView
}
export function usePostThreadQuery(uri: string | undefined) {
const queryClient = useQueryClient()
const agent = useAgent()
return useQuery<PostThreadQueryData, Error>({
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<string>,
threadgateRecordHiddenReplies: Set<string>,
fetchedAtCache: Map<string, number>,
fetchedAt: number,
randomCache: Map<string, number>,
): 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<AppBskyFeedPost.Record>(
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<ThreadNode, void> {
const atUri = new AtUri(uri)
const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
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<AppBskyActorDefs.ProfileViewBasic, void> {
const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
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<ThreadNode, void> {
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)
},
}
}
+29 -47
View File
@@ -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<AppBskyFeedDefs.ThreadgateView | undefined>(
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],
})
@@ -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<typeof createPostThreadQueryKey>
postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey>
}
const PostThreadContext = createContext<PostThreadContextType | undefined>(
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 (
<PostThreadContext.Provider value={context}>
{children}
</PostThreadContext.Provider>
)
}
+24 -15
View File
@@ -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,
])
}
+51 -1
View File
@@ -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<T>(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<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
context.postThreadQueryKey,
data => {
if (!data) return
return {
...data,
thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
...data.thread,
]),
}
},
)
},
[qc, context],
)
}
+10 -9
View File
@@ -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,
}),
)
}