diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts new file mode 100644 index 0000000000..f3a870a4c8 --- /dev/null +++ b/src/state/queries/postgate/index.ts @@ -0,0 +1,138 @@ +import {AppBskyFeedPostgate, AtUri, BskyAgent} from '@atproto/api' +import {useMutation} from '@tanstack/react-query' + +import {networkRetry} from '#/lib/async/retry' +import { + createPostgateRecord, + mergePostgateRecords, + POSTGATE_COLLECTION, +} from '#/state/queries/postgate/util' +import {useAgent} from '#/state/session' + +export async function getPostgateRecord({ + agent, + postUri, +}: { + agent: BskyAgent + postUri: string +}): Promise { + const urip = new AtUri(postUri) + + if (!urip.host.startsWith('did:')) { + const res = await agent.resolveHandle({ + handle: urip.host, + }) + urip.host = res.data.did + } + + try { + const {data} = await networkRetry(2, () => + agent.api.com.atproto.repo.getRecord({ + repo: urip.host, + collection: POSTGATE_COLLECTION, + rkey: urip.rkey, + }), + ) + + if (data.value && AppBskyFeedPostgate.isRecord(data.value)) { + return data.value + } else { + return undefined + } + } catch (e: any) { + if (e.message.includes(`Could not locate record:`)) { + return undefined + } else { + throw new Error(`Failed to get postgate record`, {cause: e}) + } + } +} + +export async function writePostgateRecord({ + agent, + postUri, + postgate, +}: { + agent: BskyAgent + postUri: string + postgate: AppBskyFeedPostgate.Record +}) { + const postUrip = new AtUri(postUri) + const record = createPostgateRecord({ + post: postUri, + detachedQuotes: postgate.detachedQuotes, + }) + + await networkRetry(2, () => + agent.api.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: POSTGATE_COLLECTION, + rkey: postUrip.rkey, + record, + }), + ) +} + +export async function upsertPostgate( + { + agent, + postUri, + }: { + agent: BskyAgent + postUri: string + }, + callback: ( + postgate: AppBskyFeedPostgate.Record | undefined, + ) => Promise, +) { + const prev = await getPostgateRecord({ + agent, + postUri, + }) + const next = await callback(prev) + if (!next) return + await writePostgateRecord({ + agent, + postUri, + postgate: next, + }) +} + +export function useToggleQuoteDetachmentMutation() { + const agent = useAgent() + + return useMutation({ + mutationFn: async ({ + postUri, + quotedUri, + action, + }: { + postUri: string + quotedUri: string + action: 'detach' | 'reattach' + }) => { + await upsertPostgate({agent, postUri: quotedUri}, async prev => { + if (prev) { + if (action === 'detach') { + return mergePostgateRecords(prev, { + detachedQuotes: [postUri], + }) + } else if (action === 'reattach') { + return { + ...prev, + detachedQuotes: + prev.detachedQuotes?.filter(uri => uri !== postUri) || [], + } + } + } else { + if (action === 'detach') { + return createPostgateRecord({ + post: quotedUri, + detachedQuotes: [postUri], + }) + } + } + }) + }, + }) +} diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts new file mode 100644 index 0000000000..52df694a9d --- /dev/null +++ b/src/state/queries/postgate/util.ts @@ -0,0 +1,44 @@ +import {AppBskyFeedPostgate} from '@atproto/api' +import {ViewRemoved} from '@atproto/api/dist/client/types/app/bsky/embed/record' + +export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate' + +export function createPostgateRecord( + postgate: Partial, +): AppBskyFeedPostgate.Record { + if (!postgate.post) { + throw new Error(`Cannot create a postgate record without a post URI`) + } + + return { + $type: POSTGATE_COLLECTION, + createdAt: new Date().toISOString(), + post: postgate.post, + detachedQuotes: postgate.detachedQuotes || [], + } +} + +export function mergePostgateRecords( + prev: AppBskyFeedPostgate.Record, + next: Partial, +) { + const detachedQuotes = Array.from( + new Set([...(prev.detachedQuotes || []), ...(next.detachedQuotes || [])]), + ) + return createPostgateRecord({ + post: prev.post, + detachedQuotes, + }) +} + +export function createEmbedViewRemovedRecord({uri}: {uri: string}) { + const record: ViewRemoved = { + $type: 'app.bsky.embed.record#viewRemoved', + uri, + removed: true, + } + return { + $type: 'app.bsky.embed.record#view', + record, + } +} diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 64bff1281e..ee4b855307 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -7,6 +7,7 @@ import { } from 'react-native' import * as Clipboard from 'expo-clipboard' import { + AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyFeedPost, AtUri, @@ -88,6 +89,7 @@ let PostDropdownBtn = ({ timestamp: string rootPostUri?: string }): React.ReactNode => { + console.log(record) const {hasSession, currentAccount} = useSession() const theme = useTheme() const alf = useAlf() @@ -114,6 +116,20 @@ let PostDropdownBtn = ({ const postUri = post.uri const postCid = post.cid 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, + } + }, [record, currentAccount]) const rootUri = record.reply?.root?.uri || postUri const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( @@ -433,6 +449,16 @@ let PostDropdownBtn = ({ )} + + {quoteEmbed && quoteEmbed.isOwnedByViewer && ( + + {_(msg`Detach quote`)} + + + )} )}