Detached QPs and hidden replies (#4878)
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
@@ -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'}],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'},
|
||||
}
|
||||
Reference in New Issue
Block a user