diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 48183739b2..492868795c 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -1,5 +1,9 @@ import {useEffect, useMemo, useState} from 'react' -import {AppBskyFeedDefs} from '@atproto/api' +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, +} from '@atproto/api' import {QueryClient} from '@tanstack/react-query' import EventEmitter from 'eventemitter3' @@ -15,6 +19,7 @@ export interface PostShadow { likeUri: string | undefined repostUri: string | undefined isDeleted: boolean + embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined } export const POST_TOMBSTONE = Symbol('PostTombstone') @@ -86,6 +91,10 @@ function mergeShadow( repostCount = Math.max(0, repostCount) } + if (shadow.embed !== undefined && post.embed) { + post.embed = shadow.embed + } + return castAsShadow({ ...post, likeCount: likeCount, diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index 071a2e91fe..197903bee5 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -73,6 +73,30 @@ export function useGetPost() { ) } +export function useGetPosts() { + const queryClient = useQueryClient() + const agent = useAgent() + return useCallback( + async ({uris}: {uris: string[]}) => { + return queryClient.fetchQuery({ + queryKey: RQKEY(uris.join(',') || ''), + async queryFn() { + const res = await agent.getPosts({ + uris, + }) + + if (res.success) { + return res.data.posts + } else { + throw new Error('useGetPosts failed') + } + }, + }) + }, + [queryClient, agent], + ) +} + export function usePostLikeMutationQueue( post: Shadow, logContext: LogEvents['post:like']['logContext'] & diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts index f3a870a4c8..46d6109f3b 100644 --- a/src/state/queries/postgate/index.ts +++ b/src/state/queries/postgate/index.ts @@ -1,8 +1,11 @@ import {AppBskyFeedPostgate, AtUri, BskyAgent} from '@atproto/api' -import {useMutation} from '@tanstack/react-query' +import {useMutation, useQueryClient} from '@tanstack/react-query' import {networkRetry} from '#/lib/async/retry' +import {updatePostShadow} from '#/state/cache/post-shadow' +import {useGetPosts} from '#/state/queries/post' import { + createEmbed, createPostgateRecord, mergePostgateRecords, POSTGATE_COLLECTION, @@ -26,6 +29,7 @@ export async function getPostgateRecord({ } try { + // TODO don't retry on 404 const {data} = await networkRetry(2, () => agent.api.com.atproto.repo.getRecord({ repo: urip.host, @@ -100,6 +104,8 @@ export async function upsertPostgate( export function useToggleQuoteDetachmentMutation() { const agent = useAgent() + const queryClient = useQueryClient() + const getPosts = useGetPosts() return useMutation({ mutationFn: async ({ @@ -134,5 +140,16 @@ export function useToggleQuoteDetachmentMutation() { } }) }, + async onSuccess(_data, {postUri, quotedUri, action}) { + const [post, quotedPost] = await getPosts({uris: [postUri, quotedUri]}) + + updatePostShadow(queryClient, postUri, { + embed: createEmbed({ + post, + embeddedPost: quotedPost, + detached: action === 'detach', + }), + }) + }, }) } diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts index 52df694a9d..1492e2df31 100644 --- a/src/state/queries/postgate/util.ts +++ b/src/state/queries/postgate/util.ts @@ -1,4 +1,9 @@ -import {AppBskyFeedPostgate} from '@atproto/api' +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, + AppBskyFeedPostgate, +} from '@atproto/api' import {ViewRemoved} from '@atproto/api/dist/client/types/app/bsky/embed/record' export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate' @@ -42,3 +47,60 @@ export function createEmbedViewRemovedRecord({uri}: {uri: string}) { record, } } + +export function createEmbed({ + post, + embeddedPost, + detached, +}: { + post: AppBskyFeedDefs.PostView + embeddedPost: AppBskyFeedDefs.PostView + detached: boolean +}) { + if (AppBskyEmbedRecord.isView(post.embed)) { + if (detached) return createEmbedViewRemovedRecord({uri: post.uri}) + return createEmbedRecordView({post: embeddedPost}) + } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { + if (detached) + return { + ...post.embed, + record: createEmbedViewRemovedRecord({uri: post.uri}), + } + return createEmbedRecordWithmediaView({post, embeddedPost}) + } +} + +export function createEmbedRecordView({ + post, +}: { + post: AppBskyFeedDefs.PostView +}): AppBskyEmbedRecord.View { + return { + $type: 'app.bsky.embed.record#view', + record: { + $type: 'app.bsky.embed.record#viewRecord', + ...post, + value: post.record, + }, + } +} + +export function createEmbedRecordWithmediaView({ + post, + embeddedPost, +}: { + post: AppBskyFeedDefs.PostView + embeddedPost: AppBskyFeedDefs.PostView +}): AppBskyEmbedRecordWithMedia.View | undefined { + if (!AppBskyEmbedRecordWithMedia.isView(post.embed)) return + return { + ...(post.embed || {}), + record: { + record: { + $type: 'app.bsky.embed.record#viewRecord', + ...embeddedPost, + value: embeddedPost.record, + }, + }, + } +} diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index ee4b855307..c33e79d951 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -8,6 +8,7 @@ import { import * as Clipboard from 'expo-clipboard' import { AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, AtUri, @@ -32,6 +33,7 @@ import { usePostDeleteMutation, useThreadMuteMutationQueue, } from '#/state/queries/post' +import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' import {useThreadgateRecordQuery} from '#/state/queries/threadgate' import {useSession} from '#/state/session' @@ -89,7 +91,6 @@ let PostDropdownBtn = ({ timestamp: string rootPostUri?: string }): React.ReactNode => { - console.log(record) const {hasSession, currentAccount} = useSession() const theme = useTheme() const alf = useAlf() @@ -118,18 +119,38 @@ let PostDropdownBtn = ({ const postAuthor = post.author const quoteEmbed = React.useMemo(() => { if (!currentAccount) return - const quoteEmbed = - record.embed && AppBskyEmbedRecord.isMain(record.embed) - ? record.embed - : undefined - if (!quoteEmbed) return - const urip = new AtUri(quoteEmbed.record.uri) - if (urip.collection !== 'app.bsky.feed.post') return - return { - uri: quoteEmbed.record.uri, - isOwnedByViewer: urip.host === currentAccount.did, + if (!post.embed) return + + if (AppBskyEmbedRecord.isView(post.embed)) { + const isPost = AppBskyEmbedRecord.isViewRecord(post.embed.record) + const isDetachedPost = AppBskyEmbedRecord.isViewRemoved(post.embed.record) + + if (isPost || isDetachedPost) { + const urip = new AtUri(post.embed.record.uri) + + return { + uri: urip.toString(), + isOwnedByViewer: urip.host === currentAccount.did, + isDetached: isDetachedPost, + } + } + } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { + const isPost = AppBskyEmbedRecord.isViewRecord(post.embed.record.record) + const isDetachedPost = AppBskyEmbedRecord.isViewRemoved( + post.embed.record.record, + ) + + if (isPost || isDetachedPost) { + const urip = new AtUri(post.embed.record.record.uri) + + return { + uri: urip.toString(), + isOwnedByViewer: urip.host === currentAccount.did, + isDetached: isDetachedPost, + } + } } - }, [record, currentAccount]) + }, [post, currentAccount]) const rootUri = record.reply?.root?.uri || postUri const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( @@ -144,6 +165,9 @@ let PostDropdownBtn = ({ }) const isReplyHiddenByThreadgate = threadgate?.hiddenReplies?.includes(postUri) + const {mutateAsync: toggleQuoteDetachment} = + useToggleQuoteDetachmentMutation() + const href = React.useMemo(() => { const urip = new AtUri(postUri) return makeProfileLink(postAuthor, 'post', urip.rkey) @@ -273,6 +297,7 @@ let PostDropdownBtn = ({ const onToggleReplyVisibility = React.useCallback(() => { if (!rootPostUri) return + // TODO handle failure toggleReplyVisibility({ postUri: rootPostUri, replyUri: postUri, @@ -280,6 +305,17 @@ let PostDropdownBtn = ({ }) }, [isReplyHiddenByThreadgate, rootPostUri, postUri, toggleReplyVisibility]) + const onToggleQuotePostAttachment = React.useCallback(() => { + if (!quoteEmbed) return + + // TODO handle failure + toggleQuoteDetachment({ + postUri, + quotedUri: quoteEmbed.uri, + action: quoteEmbed.isDetached ? 'reattach' : 'detach', + }) + }, [quoteEmbed, postUri, toggleQuoteDetachment]) + const canEmbed = isWeb && gtMobile && !hideInPWI return ( @@ -454,7 +490,7 @@ let PostDropdownBtn = ({ + onPress={onToggleQuotePostAttachment}> {_(msg`Detach quote`)}