diff options
Diffstat (limited to 'src/state/queries/postgate')
-rw-r--r-- | src/state/queries/postgate/index.ts | 295 | ||||
-rw-r--r-- | src/state/queries/postgate/util.ts | 196 |
2 files changed, 491 insertions, 0 deletions
diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts new file mode 100644 index 000000000..149b9cbe9 --- /dev/null +++ b/src/state/queries/postgate/index.ts @@ -0,0 +1,295 @@ +import React from 'react' +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, + AppBskyFeedPostgate, + AtUri, + BskyAgent, +} from '@atproto/api' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + +import {networkRetry, retry} from '#/lib/async/retry' +import {logger} from '#/logger' +import {updatePostShadow} from '#/state/cache/post-shadow' +import {STALE} from '#/state/queries' +import {useGetPosts} from '#/state/queries/post' +import { + createMaybeDetachedQuoteEmbed, + 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<AppBskyFeedPostgate.Record | undefined> { + 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 retry( + 2, + e => { + /* + * If the record doesn't exist, we want to return null instead of + * throwing an error. NB: This will also catch reference errors, such as + * a typo in the URI. + */ + if (e.message.includes(`Could not locate record:`)) { + return false + } + return true + }, + () => + 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 the record doesn't exist, we want to return null instead of + * throwing an error. NB: This will also catch reference errors, such as + * a typo in the URI. + */ + if (e.message.includes(`Could not locate record:`)) { + return undefined + } else { + throw e + } + } +} + +export async function writePostgateRecord({ + agent, + postUri, + postgate, +}: { + agent: BskyAgent + postUri: string + postgate: AppBskyFeedPostgate.Record +}) { + const postUrip = new AtUri(postUri) + + await networkRetry(2, () => + agent.api.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: POSTGATE_COLLECTION, + rkey: postUrip.rkey, + record: postgate, + }), + ) +} + +export async function upsertPostgate( + { + agent, + postUri, + }: { + agent: BskyAgent + postUri: string + }, + callback: ( + postgate: AppBskyFeedPostgate.Record | undefined, + ) => Promise<AppBskyFeedPostgate.Record | undefined>, +) { + const prev = await getPostgateRecord({ + agent, + postUri, + }) + const next = await callback(prev) + if (!next) return + await writePostgateRecord({ + agent, + postUri, + postgate: next, + }) +} + +export const createPostgateQueryKey = (postUri: string) => [ + 'postgate-record', + postUri, +] +export function usePostgateQuery({postUri}: {postUri: string}) { + const agent = useAgent() + return useQuery({ + staleTime: STALE.SECONDS.THIRTY, + queryKey: createPostgateQueryKey(postUri), + async queryFn() { + return (await getPostgateRecord({agent, postUri})) ?? null + }, + }) +} + +export function useWritePostgateMutation() { + const agent = useAgent() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + postUri, + postgate, + }: { + postUri: string + postgate: AppBskyFeedPostgate.Record + }) => { + return writePostgateRecord({ + agent, + postUri, + postgate, + }) + }, + onSuccess(_, {postUri}) { + queryClient.invalidateQueries({ + queryKey: createPostgateQueryKey(postUri), + }) + }, + }) +} + +export function useToggleQuoteDetachmentMutation() { + const agent = useAgent() + const queryClient = useQueryClient() + const getPosts = useGetPosts() + const prevEmbed = React.useRef<AppBskyFeedDefs.PostView['embed']>() + + return useMutation({ + mutationFn: async ({ + post, + quoteUri, + action, + }: { + post: AppBskyFeedDefs.PostView + quoteUri: string + action: 'detach' | 'reattach' + }) => { + // cache here since post shadow mutates original object + prevEmbed.current = post.embed + + if (action === 'detach') { + updatePostShadow(queryClient, post.uri, { + embed: createMaybeDetachedQuoteEmbed({ + post, + quote: undefined, + quoteUri, + detached: true, + }), + }) + } + + await upsertPostgate({agent, postUri: quoteUri}, async prev => { + if (prev) { + if (action === 'detach') { + return mergePostgateRecords(prev, { + detachedEmbeddingUris: [post.uri], + }) + } else if (action === 'reattach') { + return { + ...prev, + detachedEmbeddingUris: + prev.detachedEmbeddingUris?.filter(uri => uri !== post.uri) || + [], + } + } + } else { + if (action === 'detach') { + return createPostgateRecord({ + post: quoteUri, + detachedEmbeddingUris: [post.uri], + }) + } + } + }) + }, + async onSuccess(_data, {post, quoteUri, action}) { + if (action === 'reattach') { + try { + const [quote] = await getPosts({uris: [quoteUri]}) + updatePostShadow(queryClient, post.uri, { + embed: createMaybeDetachedQuoteEmbed({ + post, + quote, + quoteUri: undefined, + detached: false, + }), + }) + } catch (e: any) { + // ok if this fails, it's just optimistic UI + logger.error(`Postgate: failed to get quote post for re-attachment`, { + safeMessage: e.message, + }) + } + } + }, + onError(_, {post, action}) { + if (action === 'detach' && prevEmbed.current) { + // detach failed, add the embed back + if ( + AppBskyEmbedRecord.isView(prevEmbed.current) || + AppBskyEmbedRecordWithMedia.isView(prevEmbed.current) + ) { + updatePostShadow(queryClient, post.uri, { + embed: prevEmbed.current, + }) + } + } + }, + onSettled() { + prevEmbed.current = undefined + }, + }) +} + +export function useToggleQuotepostEnabledMutation() { + const agent = useAgent() + + return useMutation({ + mutationFn: async ({ + postUri, + action, + }: { + postUri: string + action: 'enable' | 'disable' + }) => { + await upsertPostgate({agent, postUri: postUri}, async prev => { + if (prev) { + if (action === 'disable') { + return mergePostgateRecords(prev, { + embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}], + }) + } else if (action === 'enable') { + return { + ...prev, + embeddingRules: [], + } + } + } else { + if (action === 'disable') { + return createPostgateRecord({ + post: postUri, + embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}], + }) + } + } + }) + }, + }) +} diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts new file mode 100644 index 000000000..21509c3ac --- /dev/null +++ b/src/state/queries/postgate/util.ts @@ -0,0 +1,196 @@ +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, + AppBskyFeedPostgate, + AtUri, +} from '@atproto/api' + +export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate' + +export function createPostgateRecord( + postgate: Partial<AppBskyFeedPostgate.Record> & { + post: AppBskyFeedPostgate.Record['post'] + }, +): AppBskyFeedPostgate.Record { + return { + $type: POSTGATE_COLLECTION, + createdAt: new Date().toISOString(), + post: postgate.post, + detachedEmbeddingUris: postgate.detachedEmbeddingUris || [], + embeddingRules: postgate.embeddingRules || [], + } +} + +export function mergePostgateRecords( + prev: AppBskyFeedPostgate.Record, + next: Partial<AppBskyFeedPostgate.Record>, +) { + const detachedEmbeddingUris = Array.from( + new Set([ + ...(prev.detachedEmbeddingUris || []), + ...(next.detachedEmbeddingUris || []), + ]), + ) + const embeddingRules = [ + ...(prev.embeddingRules || []), + ...(next.embeddingRules || []), + ].filter( + (rule, i, all) => all.findIndex(_rule => _rule.$type === rule.$type) === i, + ) + return createPostgateRecord({ + post: prev.post, + detachedEmbeddingUris, + embeddingRules, + }) +} + +export function createEmbedViewDetachedRecord({uri}: {uri: string}) { + const record: AppBskyEmbedRecord.ViewDetached = { + $type: 'app.bsky.embed.record#viewDetached', + uri, + detached: true, + } + return { + $type: 'app.bsky.embed.record#view', + record, + } +} + +export function createMaybeDetachedQuoteEmbed({ + post, + quote, + quoteUri, + detached, +}: + | { + post: AppBskyFeedDefs.PostView + quote: AppBskyFeedDefs.PostView + quoteUri: undefined + detached: false + } + | { + post: AppBskyFeedDefs.PostView + quote: undefined + quoteUri: string + detached: true + }): AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined { + if (AppBskyEmbedRecord.isView(post.embed)) { + if (detached) { + return createEmbedViewDetachedRecord({uri: quoteUri}) + } else { + return createEmbedRecordView({post: quote}) + } + } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { + if (detached) { + return { + ...post.embed, + record: createEmbedViewDetachedRecord({uri: quoteUri}), + } + } else { + return createEmbedRecordWithMediaView({post, quote}) + } + } +} + +export function createEmbedViewRecordFromPost( + post: AppBskyFeedDefs.PostView, +): AppBskyEmbedRecord.ViewRecord { + return { + $type: 'app.bsky.embed.record#viewRecord', + uri: post.uri, + cid: post.cid, + author: post.author, + value: post.record, + labels: post.labels, + replyCount: post.replyCount, + repostCount: post.repostCount, + likeCount: post.likeCount, + indexedAt: post.indexedAt, + } +} + +export function createEmbedRecordView({ + post, +}: { + post: AppBskyFeedDefs.PostView +}): AppBskyEmbedRecord.View { + return { + $type: 'app.bsky.embed.record#view', + record: createEmbedViewRecordFromPost(post), + } +} + +export function createEmbedRecordWithMediaView({ + post, + quote, +}: { + post: AppBskyFeedDefs.PostView + quote: AppBskyFeedDefs.PostView +}): AppBskyEmbedRecordWithMedia.View | undefined { + if (!AppBskyEmbedRecordWithMedia.isView(post.embed)) return + return { + ...(post.embed || {}), + record: { + record: createEmbedViewRecordFromPost(quote), + }, + } +} + +export function getMaybeDetachedQuoteEmbed({ + viewerDid, + post, +}: { + viewerDid: string + post: AppBskyFeedDefs.PostView +}) { + if (AppBskyEmbedRecord.isView(post.embed)) { + // detached + if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) { + const urip = new AtUri(post.embed.record.uri) + return { + embed: post.embed, + uri: urip.toString(), + isOwnedByViewer: urip.host === viewerDid, + isDetached: true, + } + } + + // post + if (AppBskyEmbedRecord.isViewRecord(post.embed.record)) { + const urip = new AtUri(post.embed.record.uri) + return { + embed: post.embed, + uri: urip.toString(), + isOwnedByViewer: urip.host === viewerDid, + isDetached: false, + } + } + } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { + // detached + if (AppBskyEmbedRecord.isViewDetached(post.embed.record.record)) { + const urip = new AtUri(post.embed.record.record.uri) + return { + embed: post.embed, + uri: urip.toString(), + isOwnedByViewer: urip.host === viewerDid, + isDetached: true, + } + } + + // post + if (AppBskyEmbedRecord.isViewRecord(post.embed.record.record)) { + const urip = new AtUri(post.embed.record.record.uri) + return { + embed: post.embed, + uri: urip.toString(), + isOwnedByViewer: urip.host === viewerDid, + isDetached: false, + } + } + } +} + +export const embeddingRules = { + disableRule: {$type: 'app.bsky.feed.postgate#disableRule'}, +} |