diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index f6bc5871c8..9f1b2f6716 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -162,7 +162,7 @@ function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) { const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ item, isRoot, - postShadow, + // postShadow, onPostSuccess, threadgateRecord, postSource, @@ -488,7 +488,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ , +) { + applyPostCacheMutator_usePostThread({ + qc, + uri, + mutator: post => mutatePost(post, mutations), + }) + + // ... add all other queries below +} diff --git a/src/state/queries/cache/mutations.ts b/src/state/queries/cache/mutations.ts new file mode 100644 index 0000000000..850adb861c --- /dev/null +++ b/src/state/queries/cache/mutations.ts @@ -0,0 +1,72 @@ +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + type AppBskyFeedDefs, +} from '@atproto/api' + +import {type PostMutations} from '#/state/queries/cache/types' +import {DELETED_POST} from '#/state/queries/cache/util' + +/** + * Applies mutations to a post in the cache. If the post is deleted, returns a + * symbol to force individual cache mutators to handle the unknown value + * appropriately. + */ +export function mutatePost( + post: AppBskyFeedDefs.PostView, + mutations: Partial, +): AppBskyFeedDefs.PostView | typeof DELETED_POST { + if (mutations.isDeleted) { + return DELETED_POST + } + + let likeCount = post.likeCount ?? 0 + if ('likeUri' in mutations) { + const wasLiked = !!post.viewer?.like + const isLiked = !!mutations.likeUri + if (wasLiked && !isLiked) { + likeCount-- + } else if (!wasLiked && isLiked) { + likeCount++ + } + likeCount = Math.max(0, likeCount) + } + + let repostCount = post.repostCount ?? 0 + if ('repostUri' in mutations) { + const wasReposted = !!post.viewer?.repost + const isReposted = !!mutations.repostUri + if (wasReposted && !isReposted) { + repostCount-- + } else if (!wasReposted && isReposted) { + repostCount++ + } + repostCount = Math.max(0, repostCount) + } + + let embed: typeof post.embed + if ('embed' in mutations) { + if ( + (AppBskyEmbedRecord.isView(post.embed) && + AppBskyEmbedRecord.isView(mutations.embed)) || + (AppBskyEmbedRecordWithMedia.isView(post.embed) && + AppBskyEmbedRecordWithMedia.isView(mutations.embed)) + ) { + embed = mutations.embed + } + } + + return { + ...post, + embed: embed || post.embed, + likeCount: likeCount, + repostCount: repostCount, + viewer: { + ...(post.viewer || {}), + like: 'likeUri' in mutations ? mutations.likeUri : post.viewer?.like, + repost: + 'repostUri' in mutations ? mutations.repostUri : post.viewer?.repost, + pinned: 'pinned' in mutations ? mutations.pinned : post.viewer?.pinned, + }, + } +} diff --git a/src/state/queries/cache/types.ts b/src/state/queries/cache/types.ts new file mode 100644 index 0000000000..1162d2142d --- /dev/null +++ b/src/state/queries/cache/types.ts @@ -0,0 +1,39 @@ +import { + type AppBskyEmbedRecord, + type AppBskyEmbedRecordWithMedia, + type AppBskyFeedDefs, +} from '@atproto/api' +import {type QueryClient} from '@tanstack/react-query' + +import {type DELETED_POST} from '#/state/queries/cache/util' + +/** + * Available mutations for a post. + */ +export type PostMutations = { + likeUri: string | undefined + repostUri: string | undefined + isDeleted: boolean + embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined + pinned: boolean +} + +/** + * Applies mutations to a post. + */ +export type ApplyPostCacheMutator = (params: { + /** + * The query client to use for cache updates. + */ + qc: QueryClient + /** + * The URI of the post to mutate. + */ + uri: string + /** + * The mutation function that applies changes to the post. + */ + mutator: ( + post: AppBskyFeedDefs.PostView, + ) => AppBskyFeedDefs.PostView | typeof DELETED_POST +}) => void diff --git a/src/state/queries/cache/util.ts b/src/state/queries/cache/util.ts new file mode 100644 index 0000000000..411fd8ebe2 --- /dev/null +++ b/src/state/queries/cache/util.ts @@ -0,0 +1,34 @@ +import {AtUri} from '@atproto/api' + +import {type ApplyPostCacheMutator} from '#/state/queries/cache/types' + +/** + * Symbol used to indicate that a value has been deleted. + */ +export const DELETED_POST = Symbol('DELETED_POST') + +/** + * Sugar for creating a post cache mutator, for use alongside the definitions + * for all post queries. + */ +export function createApplyPostCacheMutator( + applyPostCacheMutator: ApplyPostCacheMutator, +) { + return applyPostCacheMutator +} + +/** + * Checks if a source URI matches a given URI, or the same URI with handle in + * place of a DID. + */ +export function uriMatches( + sourceUri: string, + matchUri: string, + matchHandle?: string, +) { + const source = new AtUri(sourceUri) + if (source.host.startsWith('did:')) return source.toString() === matchUri + if (!matchHandle) return false + source.host = matchHandle + return source.toString() === matchUri +} diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index e92847e75a..456fb8dacc 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -7,6 +7,7 @@ import {type LogEvents, toClout} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {updatePostShadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/types' +import {applyPostCacheMutations} from '#/state/queries/cache' import {useAgent, useSession} from '#/state/session' import * as userActionHistory from '#/state/userActionHistory' import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' @@ -135,7 +136,7 @@ export function usePostLikeMutationQueue( }, onSuccess(finalLikeUri) { // finalize - updatePostShadow(queryClient, postUri, { + applyPostCacheMutations(queryClient, postUri, { likeUri: finalLikeUri, }) }, @@ -143,7 +144,7 @@ export function usePostLikeMutationQueue( const queueLike = useCallback(() => { // optimistically update - updatePostShadow(queryClient, postUri, { + applyPostCacheMutations(queryClient, postUri, { likeUri: 'pending', }) return queueToggle(true) @@ -151,7 +152,7 @@ export function usePostLikeMutationQueue( const queueUnlike = useCallback(() => { // optimistically update - updatePostShadow(queryClient, postUri, { + applyPostCacheMutations(queryClient, postUri, { likeUri: undefined, }) return queueToggle(false) diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts index 871033395f..c9b88c54b7 100644 --- a/src/state/queries/usePostThread/queryCache.ts +++ b/src/state/queries/usePostThread/queryCache.ts @@ -9,6 +9,11 @@ import { } from '@atproto/api' import {type QueryClient} from '@tanstack/react-query' +import { + createApplyPostCacheMutator, + DELETED_POST, + uriMatches, +} from '#/state/queries/cache/util' import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' @@ -298,3 +303,42 @@ export function* findAllProfilesInQueryData( } } } + +export const applyPostCacheMutator = createApplyPostCacheMutator( + ({qc, uri, mutator}) => { + qc.setQueriesData( + {queryKey: [postThreadQueryKeyRoot]}, + data => { + if (!data) return + + const thread = [] + + for (const item of data.thread) { + if ( + AppBskyUnspeccedDefs.isThreadItemPost(item.value) && + uriMatches(uri, item.value.post.uri, item.value.post.author.handle) + ) { + const post = mutator(item.value.post) + + if (post !== DELETED_POST) { + thread.push({ + ...item, + value: { + ...item.value, + post, + }, + }) + } + } else { + thread.push(item) + } + } + + return { + ...data, + thread, + } + }, + ) + }, +)