diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx index 3b9adf90f5..c9fca6ba69 100644 --- a/src/components/WhoCanReply.tsx +++ b/src/components/WhoCanReply.tsx @@ -11,13 +11,13 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {createThreadgate} from '#/lib/api' import {until} from '#/lib/async/until' import {HITSLOP_10} from '#/lib/constants' import {makeListLink, makeProfileLink} from '#/lib/routes/links' import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' +import {updateThreadgateAllow} from '#/state/queries/threadgate' import {threadgateRecordQueryKeyRoot} from '#/state/queries/threadgate' import { ThreadgateAllowUISetting, @@ -88,15 +88,13 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { return } try { - if (newSettings.length) { - await createThreadgate(agent, post.uri, newSettings) - } else { - await agent.api.com.atproto.repo.deleteRecord({ - repo: agent.session!.did, - collection: 'app.bsky.feed.threadgate', - rkey: new AtUri(post.uri).rkey, - }) - } + await updateThreadgateAllow({ + agent, + postUri: post.uri, + allow: newSettings, + }) + + // TODO await whenAppViewReady(agent, post.uri, res => { const thread = res.data.thread if (AppBskyFeedDefs.isThreadViewPost(thread)) { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 751c97122b..0196bb5c58 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -12,9 +12,10 @@ import {AtUri} from '@atproto/api' import {logger} from '#/logger' import { - createThreadgate as upsertThreadgate, + createThreadgateRecord, ThreadgateAllowUISetting, - threadgateAllowUISettingToAllowType, + threadgateAllowUISettingToAllowRecordValue, + writeThreadgateRecord, } from '#/state/queries/threadgate' import {isNetworkError} from 'lib/strings/errors' import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' @@ -265,7 +266,14 @@ export async function post(agent: BskyAgent, opts: PostOpts) { try { // TODO: this needs to be batch-created with the post! if (opts.threadgate?.length) { - await createThreadgate(agent, res.uri, opts.threadgate) + await writeThreadgateRecord({ + agent, + postUri: res.uri, + threadgate: createThreadgateRecord({ + post: res.uri, + allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate), + }), + }) } } catch (e: any) { console.error(`Failed to create threadgate: ${e.toString()}`) @@ -277,15 +285,6 @@ export async function post(agent: BskyAgent, opts: PostOpts) { return res } -export async function createThreadgate( - agent: BskyAgent, - postUri: string, - threadgate: ThreadgateAllowUISetting[], -) { - const allow = threadgateAllowUISettingToAllowType(threadgate) - return upsertThreadgate({agent, postUri, threadgate: {allow}}) -} - // helpers // = diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts index 54cf773141..ed0643a482 100644 --- a/src/state/queries/threadgate/index.ts +++ b/src/state/queries/threadgate/index.ts @@ -2,9 +2,11 @@ import {AppBskyFeedThreadgate, AtUri, BskyAgent} from '@atproto/api' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {networkRetry} from '#/lib/async/retry' +import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' import { createThreadgateRecord, mergeThreadgateRecords, + threadgateAllowUISettingToAllowRecordValue, } from '#/state/queries/threadgate/util' import {useAgent} from '#/state/session' @@ -31,21 +33,10 @@ export function useThreadgateRecordQuery({ queryKey: createThreadgateRecordQueryKey(postUri || ''), placeholderData: initialData, async queryFn() { - const urip = new AtUri(postUri!) - - if (!urip.host.startsWith('did:')) { - const res = await agent.resolveHandle({ - handle: urip.host, - }) - urip.host = res.data.did - } - - const {value} = await agent.api.app.bsky.feed.threadgate.get({ - repo: urip.host, - rkey: urip.rkey, + return getThreadgateRecord({ + agent, + postUri: postUri!, }) - - return value }, }) } @@ -57,19 +48,28 @@ export async function getThreadgateRecord({ agent: BskyAgent postUri: string }): Promise { - const postUrip = new AtUri(postUri) + 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: agent.session!.did, + repo: urip.host, collection: 'app.bsky.feed.threadgate', - rkey: postUrip.rkey, + rkey: urip.rkey, }), ) if (data.value && AppBskyFeedThreadgate.isRecord(data.value)) { return data.value + } else { + return undefined } } catch (e: any) { if (e.message.includes(`Could not locate record:`)) { @@ -80,60 +80,7 @@ export async function getThreadgateRecord({ } } -export async function createThreadgate({ - agent, - postUri, - threadgate, -}: { - agent: BskyAgent - postUri: string - threadgate: Partial -}) { - const postUrip = new AtUri(postUri) - - const {data} = await networkRetry(2, () => - agent.api.com.atproto.repo.getRecord({ - repo: agent.session!.did, - collection: 'app.bsky.feed.threadgate', - rkey: postUrip.rkey, - }), - ) - - if (data.value && AppBskyFeedThreadgate.isRecord(data.value)) { - // has existing, merge - const prev = data.value - const merged = mergeThreadgateRecords(prev, threadgate) - - await networkRetry(2, () => - agent.api.com.atproto.repo.putRecord({ - repo: agent.session!.did, - collection: 'app.bsky.feed.threadgate', - rkey: postUrip.rkey, - record: merged, - }), - ) - } else { - // no existing, create new - const record: AppBskyFeedThreadgate.Record = { - $type: 'app.bsky.feed.threadgate', - post: postUri, - allow: threadgate.allow || [], - hiddenReplies: threadgate.hiddenReplies || [], - createdAt: new Date().toISOString(), - } - - await networkRetry(2, () => - agent.api.com.atproto.repo.putRecord({ - repo: agent.session!.did, - collection: 'app.bsky.feed.threadgate', - rkey: postUrip.rkey, - record, - }), - ) - } -} - -export async function overwriteThreadgateRecord({ +export async function writeThreadgateRecord({ agent, postUri, threadgate, @@ -143,13 +90,11 @@ export async function overwriteThreadgateRecord({ threadgate: AppBskyFeedThreadgate.Record }) { const postUrip = new AtUri(postUri) - const record: AppBskyFeedThreadgate.Record = { - $type: 'app.bsky.feed.threadgate', + const record = createThreadgateRecord({ post: postUri, - allow: threadgate.allow || [], + allow: threadgate.allow, // can/should be undefined! hiddenReplies: threadgate.hiddenReplies || [], - createdAt: new Date().toISOString(), - } + }) await networkRetry(2, () => agent.api.com.atproto.repo.putRecord({ @@ -161,29 +106,57 @@ export async function overwriteThreadgateRecord({ ) } -export function useCreateThreadgateMutation() { - const agent = useAgent() - const queryClient = useQueryClient() +export async function upsertThreadgate( + { + agent, + postUri, + }: { + agent: BskyAgent + postUri: string + }, + callback: ( + threadgate: AppBskyFeedThreadgate.Record | undefined, + ) => Promise, +) { + const prev = await getThreadgateRecord({ + agent, + postUri, + }) + const next = await callback(prev) + if (!next) return + await writeThreadgateRecord({ + agent, + postUri, + threadgate: next, + }) +} - return useMutation({ - mutationFn: async ({ - postUri, - threadgate, - }: { - postUri: string - threadgate: Partial - }) => { - return createThreadgate({ - agent, - postUri, - threadgate, +/** + * Update the allow list for a threadgate record. + * + * Note: to allow everyone to reply, pass `allow: []`. + */ +export async function updateThreadgateAllow({ + agent, + postUri, + allow, +}: { + agent: BskyAgent + postUri: string + allow: ThreadgateAllowUISetting[] +}) { + return upsertThreadgate({agent, postUri}, async prev => { + if (prev) { + return { + ...prev, + allow: threadgateAllowUISettingToAllowRecordValue(allow), + } + } else { + return createThreadgateRecord({ + post: postUri, + allow: threadgateAllowUISettingToAllowRecordValue(allow), }) - }, - onSuccess() { - queryClient.invalidateQueries({ - queryKey: [threadgateRecordQueryKeyRoot], - }) - }, + } }) } @@ -201,44 +174,28 @@ export function useToggleReplyVisibilityMutation() { replyUri: string action: 'hide' | 'show' }) => { - const prev = await getThreadgateRecord({ - agent, - postUri, - }) - - if (prev) { - let threadgate = prev - - if (action === 'hide') { - threadgate = mergeThreadgateRecords(prev, { - hiddenReplies: [replyUri], - }) - } else if (action === 'show') { - threadgate = { - ...prev, - hiddenReplies: - prev.hiddenReplies?.filter(uri => uri !== replyUri) || [], + await upsertThreadgate({agent, postUri}, async prev => { + if (prev) { + if (action === 'hide') { + return mergeThreadgateRecords(prev, { + hiddenReplies: [replyUri], + }) + } else if (action === 'show') { + return { + ...prev, + hiddenReplies: + prev.hiddenReplies?.filter(uri => uri !== replyUri) || [], + } + } + } else { + if (action === 'hide') { + return createThreadgateRecord({ + post: postUri, + hiddenReplies: [replyUri], + }) } } - - await overwriteThreadgateRecord({ - agent, - postUri, - threadgate, - }) - } else { - if (action === 'hide') { - const threadgate = createThreadgateRecord({ - post: postUri, - hiddenReplies: [replyUri], - }) - await overwriteThreadgateRecord({ - agent, - postUri, - threadgate, - }) - } - } + }) }, onSuccess() { queryClient.invalidateQueries({ diff --git a/src/state/queries/threadgate/util.ts b/src/state/queries/threadgate/util.ts index 367e45377a..590c1cdb80 100644 --- a/src/state/queries/threadgate/util.ts +++ b/src/state/queries/threadgate/util.ts @@ -15,12 +15,21 @@ export function threadgateViewToAllowUISetting( AppBskyFeedThreadgate.validateRecord(threadgate.record).success ? threadgate.record : null - if (!record) { + /* + * If record doesn't exist (default), or if `record.allow === undefined`, it means + * anyone can reply. + * + * If `record.allow === []` it means no one can reply, and we translate to UI code + * here. This was a historical choice, and we have no lexicon representation + * for 'replies disabled' other than an empty array. + */ + if (!record || record.allow === undefined) { return [] } - if (!record.allow?.length) { + if (record.allow.length === 0) { return [{type: 'nobody'}] } + const settings: ThreadgateAllowUISetting[] = record.allow .map(allow => { let setting: ThreadgateAllowUISetting | undefined @@ -38,12 +47,18 @@ export function threadgateViewToAllowUISetting( } /** - * Converts a list of {@link ThreadgateAllowUISetting} to the `allow` prop on - * {@link AppBskyFeedThreadgate.Record}, + * Converts an array of {@link ThreadgateAllowUISetting} to the `allow` prop on + * {@link AppBskyFeedThreadgate.Record}. + * + * If the array passed is empty, we infer that to mean anyone can reply, and + * return undefined. An undefined value in the record is interpretted as anyone + * can reply, whereas an empty array means no on can reply. */ -export function threadgateAllowUISettingToAllowType( +export function threadgateAllowUISettingToAllowRecordValue( threadgate: ThreadgateAllowUISetting[], -) { +): AppBskyFeedThreadgate.Record['allow'] { + if (threadgate.length === 0) return undefined + let allow: ( | AppBskyFeedThreadgate.MentionRule | AppBskyFeedThreadgate.FollowingRule @@ -90,6 +105,14 @@ export function mergeThreadgateRecords( }) } +/** + * Create a new {@link AppBskyFeedThreadgate.Record} object with the given + * properties. + * + * Note: setting `allow` to `undefined` resets and allows everyone to reply. An + * empty array means that no one can reply. This is a bit of a hack bc of how + * these were designed, but should be fine as long as we're careful. + */ export function createThreadgateRecord( threadgate: Partial, ): AppBskyFeedThreadgate.Record { @@ -101,7 +124,7 @@ export function createThreadgateRecord( $type: 'app.bsky.feed.threadgate', post: threadgate.post, createdAt: new Date().toISOString(), - allow: threadgate.allow || [], + allow: threadgate.allow, // can be undefined! hiddenReplies: threadgate.hiddenReplies || [], } }