diff options
Diffstat (limited to 'src/state/queries')
-rw-r--r-- | src/state/queries/notifications/feed.ts | 55 | ||||
-rw-r--r-- | src/state/queries/post-thread.ts | 18 | ||||
-rw-r--r-- | src/state/queries/post.ts | 24 | ||||
-rw-r--r-- | src/state/queries/postgate/index.ts | 295 | ||||
-rw-r--r-- | src/state/queries/postgate/util.ts | 196 | ||||
-rw-r--r-- | src/state/queries/threadgate.ts | 38 | ||||
-rw-r--r-- | src/state/queries/threadgate/index.ts | 358 | ||||
-rw-r--r-- | src/state/queries/threadgate/types.ts | 6 | ||||
-rw-r--r-- | src/state/queries/threadgate/util.ts | 141 |
9 files changed, 1079 insertions, 52 deletions
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 997076e81..55e048308 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -16,7 +16,7 @@ * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. */ -import {useEffect, useRef} from 'react' +import {useCallback, useEffect, useMemo, useRef} from 'react' import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' import { InfiniteData, @@ -27,6 +27,7 @@ import { } from '@tanstack/react-query' import {useAgent} from '#/state/session' +import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' import {useModerationOpts} from '../../preferences/moderation-opts' import {STALE} from '..' import { @@ -58,11 +59,18 @@ export function useNotificationFeedQuery(opts?: { const moderationOpts = useModerationOpts() const unreads = useUnreadNotificationsApi() const enabled = opts?.enabled !== false + const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris() // false: force showing all notifications // undefined: let the server decide const priority = opts?.overridePriorityNotifications ? false : undefined + const selectArgs = useMemo(() => { + return { + hiddenReplyUris, + } + }, [hiddenReplyUris]) + const query = useInfiniteQuery< FeedPage, Error, @@ -101,20 +109,41 @@ export function useNotificationFeedQuery(opts?: { initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, enabled, - select(data: InfiniteData<FeedPage>) { - // override 'isRead' using the first page's returned seenAt - // we do this because the `markAllRead()` call above will - // mark subsequent pages as read prematurely - const seenAt = data.pages[0]?.seenAt || new Date() - for (const page of data.pages) { - for (const item of page.items) { - item.notification.isRead = - seenAt > new Date(item.notification.indexedAt) + select: useCallback( + (data: InfiniteData<FeedPage>) => { + const {hiddenReplyUris} = selectArgs + + // override 'isRead' using the first page's returned seenAt + // we do this because the `markAllRead()` call above will + // mark subsequent pages as read prematurely + const seenAt = data.pages[0]?.seenAt || new Date() + for (const page of data.pages) { + for (const item of page.items) { + item.notification.isRead = + seenAt > new Date(item.notification.indexedAt) + } } - } - return data - }, + data = { + ...data, + pages: data.pages.map(page => { + return { + ...page, + items: page.items.filter(item => { + const isHiddenReply = + item.type === 'reply' && + item.subjectUri && + hiddenReplyUris.has(item.subjectUri) + return !isHiddenReply + }), + } + }), + } + + return data + }, + [selectArgs], + ), }) // The server may end up returning an empty page, a page with too few items, diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index fd419d1c4..3370c3617 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -138,6 +138,7 @@ export function sortThread( modCache: ThreadModerationCache, currentDid: string | undefined, justPostedUris: Set<string>, + threadgateRecordHiddenReplies: Set<string>, ): ThreadNode { if (node.type !== 'post') { return node @@ -185,6 +186,14 @@ export function sortThread( return 1 // current account's reply } + const aHidden = threadgateRecordHiddenReplies.has(a.uri) + const bHidden = threadgateRecordHiddenReplies.has(b.uri) + if (aHidden && !aIsBySelf && !bHidden) { + return 1 + } else if (bHidden && !bIsBySelf && !aHidden) { + return -1 + } + const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur) const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur) if (aBlur !== bBlur) { @@ -222,7 +231,14 @@ export function sortThread( return b.post.indexedAt.localeCompare(a.post.indexedAt) }) node.replies.forEach(reply => - sortThread(reply, opts, modCache, currentDid, justPostedUris), + sortThread( + reply, + opts, + modCache, + currentDid, + justPostedUris, + threadgateRecordHiddenReplies, + ), ) } return node diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index 071a2e91f..197903bee 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<AppBskyFeedDefs.PostView>, logContext: LogEvents['post:like']['logContext'] & 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'}, +} diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts deleted file mode 100644 index 8b6aeba6c..000000000 --- a/src/state/queries/threadgate.ts +++ /dev/null @@ -1,38 +0,0 @@ -import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api' - -export type ThreadgateSetting = - | {type: 'nobody'} - | {type: 'mention'} - | {type: 'following'} - | {type: 'list'; list: unknown} - -export function threadgateViewToSettings( - threadgate: AppBskyFeedDefs.ThreadgateView | undefined, -): ThreadgateSetting[] { - const record = - threadgate && - AppBskyFeedThreadgate.isRecord(threadgate.record) && - AppBskyFeedThreadgate.validateRecord(threadgate.record).success - ? threadgate.record - : null - if (!record) { - return [] - } - if (!record.allow?.length) { - return [{type: 'nobody'}] - } - const settings: ThreadgateSetting[] = record.allow - .map(allow => { - let setting: ThreadgateSetting | undefined - if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { - setting = {type: 'mention'} - } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { - setting = {type: 'following'} - } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') { - setting = {type: 'list', list: allow.list} - } - return setting - }) - .filter(n => !!n) - return settings -} diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts new file mode 100644 index 000000000..a88197cd5 --- /dev/null +++ b/src/state/queries/threadgate/index.ts @@ -0,0 +1,358 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetPostThread, + AppBskyFeedThreadgate, + AtUri, + BskyAgent, +} from '@atproto/api' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + +import {networkRetry, retry} from '#/lib/async/retry' +import {until} from '#/lib/async/until' +import {STALE} from '#/state/queries' +import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread' +import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' +import { + createThreadgateRecord, + mergeThreadgateRecords, + threadgateAllowUISettingToAllowRecordValue, + threadgateViewToAllowUISetting, +} from '#/state/queries/threadgate/util' +import {useAgent} from '#/state/session' +import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies' + +export * from '#/state/queries/threadgate/types' +export * from '#/state/queries/threadgate/util' + +export const threadgateRecordQueryKeyRoot = 'threadgate-record' +export const createThreadgateRecordQueryKey = (uri: string) => [ + threadgateRecordQueryKeyRoot, + uri, +] + +export function useThreadgateRecordQuery({ + enabled, + postUri, + initialData, +}: { + enabled?: boolean + postUri?: string + initialData?: AppBskyFeedThreadgate.Record +} = {}) { + const agent = useAgent() + + return useQuery({ + enabled: enabled ?? !!postUri, + queryKey: createThreadgateRecordQueryKey(postUri || ''), + placeholderData: initialData, + staleTime: STALE.MINUTES.ONE, + async queryFn() { + return getThreadgateRecord({ + agent, + postUri: postUri!, + }) + }, + }) +} + +export const threadgateViewQueryKeyRoot = 'threadgate-view' +export const createThreadgateViewQueryKey = (uri: string) => [ + threadgateViewQueryKeyRoot, + uri, +] +export function useThreadgateViewQuery({ + postUri, + initialData, +}: { + postUri?: string + initialData?: AppBskyFeedDefs.ThreadgateView +} = {}) { + const agent = useAgent() + + return useQuery({ + enabled: !!postUri, + queryKey: createThreadgateViewQueryKey(postUri || ''), + placeholderData: initialData, + staleTime: STALE.MINUTES.ONE, + async queryFn() { + return getThreadgateView({ + agent, + postUri: postUri!, + }) + }, + }) +} + +export async function getThreadgateView({ + agent, + postUri, +}: { + agent: BskyAgent + postUri: string +}) { + const {data} = await agent.app.bsky.feed.getPostThread({ + uri: postUri!, + depth: 0, + }) + + if (AppBskyFeedDefs.isThreadViewPost(data.thread)) { + return data.thread.post.threadgate ?? null + } + + return null +} + +export async function getThreadgateRecord({ + agent, + postUri, +}: { + agent: BskyAgent + postUri: string +}): Promise<AppBskyFeedThreadgate.Record | null> { + 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: 'app.bsky.feed.threadgate', + rkey: urip.rkey, + }), + ) + + if (data.value && AppBskyFeedThreadgate.isRecord(data.value)) { + return data.value + } else { + return null + } + } 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 null + } else { + throw e + } + } +} + +export async function writeThreadgateRecord({ + agent, + postUri, + threadgate, +}: { + agent: BskyAgent + postUri: string + threadgate: AppBskyFeedThreadgate.Record +}) { + const postUrip = new AtUri(postUri) + const record = createThreadgateRecord({ + post: postUri, + allow: threadgate.allow, // can/should be undefined! + hiddenReplies: threadgate.hiddenReplies || [], + }) + + 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 upsertThreadgate( + { + agent, + postUri, + }: { + agent: BskyAgent + postUri: string + }, + callback: ( + threadgate: AppBskyFeedThreadgate.Record | null, + ) => Promise<AppBskyFeedThreadgate.Record | undefined>, +) { + const prev = await getThreadgateRecord({ + agent, + postUri, + }) + const next = await callback(prev) + if (!next) return + await writeThreadgateRecord({ + agent, + postUri, + threadgate: next, + }) +} + +/** + * Update the allow list for a threadgate record. + */ +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), + }) + } + }) +} + +export function useSetThreadgateAllowMutation() { + const agent = useAgent() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + postUri, + allow, + }: { + 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), + }) + } + }) + }, + async onSuccess(_, {postUri, allow}) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + (res: AppBskyFeedGetPostThread.Response) => { + const thread = res.data.thread + if (AppBskyFeedDefs.isThreadViewPost(thread)) { + const fetchedSettings = threadgateViewToAllowUISetting( + thread.post.threadgate, + ) + return JSON.stringify(fetchedSettings) === JSON.stringify(allow) + } + return false + }, + () => { + return agent.app.bsky.feed.getPostThread({ + uri: postUri, + depth: 0, + }) + }, + ) + + queryClient.invalidateQueries({ + queryKey: [postThreadQueryKeyRoot], + }) + queryClient.invalidateQueries({ + queryKey: [threadgateRecordQueryKeyRoot], + }) + queryClient.invalidateQueries({ + queryKey: [threadgateViewQueryKeyRoot], + }) + }, + }) +} + +export function useToggleReplyVisibilityMutation() { + const agent = useAgent() + const queryClient = useQueryClient() + const hiddenReplies = useThreadgateHiddenReplyUrisAPI() + + return useMutation({ + mutationFn: async ({ + postUri, + replyUri, + action, + }: { + postUri: string + replyUri: string + action: 'hide' | 'show' + }) => { + if (action === 'hide') { + hiddenReplies.addHiddenReplyUri(replyUri) + } else if (action === 'show') { + hiddenReplies.removeHiddenReplyUri(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], + }) + } + } + }) + }, + onSuccess() { + queryClient.invalidateQueries({ + queryKey: [threadgateRecordQueryKeyRoot], + }) + }, + onError(_, {replyUri, action}) { + if (action === 'hide') { + hiddenReplies.removeHiddenReplyUri(replyUri) + } else if (action === 'show') { + hiddenReplies.addHiddenReplyUri(replyUri) + } + }, + }) +} diff --git a/src/state/queries/threadgate/types.ts b/src/state/queries/threadgate/types.ts new file mode 100644 index 000000000..0cbea311c --- /dev/null +++ b/src/state/queries/threadgate/types.ts @@ -0,0 +1,6 @@ +export type ThreadgateAllowUISetting = + | {type: 'everybody'} + | {type: 'nobody'} + | {type: 'mention'} + | {type: 'following'} + | {type: 'list'; list: unknown} diff --git a/src/state/queries/threadgate/util.ts b/src/state/queries/threadgate/util.ts new file mode 100644 index 000000000..09ae0a0c1 --- /dev/null +++ b/src/state/queries/threadgate/util.ts @@ -0,0 +1,141 @@ +import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api' + +import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' + +export function threadgateViewToAllowUISetting( + threadgateView: AppBskyFeedDefs.ThreadgateView | undefined, +): ThreadgateAllowUISetting[] { + const threadgate = + threadgateView && + AppBskyFeedThreadgate.isRecord(threadgateView.record) && + AppBskyFeedThreadgate.validateRecord(threadgateView.record).success + ? threadgateView.record + : undefined + return threadgateRecordToAllowUISetting(threadgate) +} + +/** + * Converts a full {@link AppBskyFeedThreadgate.Record} to a list of + * {@link ThreadgateAllowUISetting}, for use by app UI. + */ +export function threadgateRecordToAllowUISetting( + threadgate: AppBskyFeedThreadgate.Record | undefined, +): ThreadgateAllowUISetting[] { + /* + * If `threadgate` doesn't exist (default), or if `threadgate.allow === undefined`, it means + * anyone can reply. + * + * If `threadgate.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 (!threadgate || threadgate.allow === undefined) { + return [{type: 'everybody'}] + } + if (threadgate.allow.length === 0) { + return [{type: 'nobody'}] + } + + const settings: ThreadgateAllowUISetting[] = threadgate.allow + .map(allow => { + let setting: ThreadgateAllowUISetting | undefined + if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { + setting = {type: 'mention'} + } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { + setting = {type: 'following'} + } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') { + setting = {type: 'list', list: allow.list} + } + return setting + }) + .filter(n => !!n) + return settings +} + +/** + * Converts an array of {@link ThreadgateAllowUISetting} to the `allow` prop on + * {@link AppBskyFeedThreadgate.Record}. + * + * If the `allow` property on the record is undefined, we infer that to mean + * that everyone can reply. If it's an empty array, we infer that to mean that + * no one can reply. + */ +export function threadgateAllowUISettingToAllowRecordValue( + threadgate: ThreadgateAllowUISetting[], +): AppBskyFeedThreadgate.Record['allow'] { + if (threadgate.find(v => v.type === 'everybody')) { + return undefined + } + + let allow: ( + | AppBskyFeedThreadgate.MentionRule + | AppBskyFeedThreadgate.FollowingRule + | AppBskyFeedThreadgate.ListRule + )[] = [] + + if (!threadgate.find(v => v.type === 'nobody')) { + for (const rule of threadgate) { + if (rule.type === 'mention') { + allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'}) + } else if (rule.type === 'following') { + allow.push({$type: 'app.bsky.feed.threadgate#followingRule'}) + } else if (rule.type === 'list') { + allow.push({ + $type: 'app.bsky.feed.threadgate#listRule', + list: rule.list, + }) + } + } + } + + return allow +} + +/** + * Merges two {@link AppBskyFeedThreadgate.Record} objects, combining their + * `allow` and `hiddenReplies` arrays and de-deduplicating them. + * + * Note: `allow` can be undefined here, be sure you don't accidentally set it + * to an empty array. See other comments in this file. + */ +export function mergeThreadgateRecords( + prev: AppBskyFeedThreadgate.Record, + next: Partial<AppBskyFeedThreadgate.Record>, +): AppBskyFeedThreadgate.Record { + // can be undefined if everyone can reply! + const allow: AppBskyFeedThreadgate.Record['allow'] | undefined = + prev.allow || next.allow + ? [...(prev.allow || []), ...(next.allow || [])].filter( + (v, i, a) => a.findIndex(t => t.$type === v.$type) === i, + ) + : undefined + const hiddenReplies = Array.from( + new Set([...(prev.hiddenReplies || []), ...(next.hiddenReplies || [])]), + ) + + return createThreadgateRecord({ + post: prev.post, + allow, // can be undefined! + hiddenReplies, + }) +} + +/** + * Create a new {@link AppBskyFeedThreadgate.Record} object with the given + * properties. + */ +export function createThreadgateRecord( + threadgate: Partial<AppBskyFeedThreadgate.Record>, +): AppBskyFeedThreadgate.Record { + if (!threadgate.post) { + throw new Error('Cannot create a threadgate record without a post URI') + } + + return { + $type: 'app.bsky.feed.threadgate', + post: threadgate.post, + createdAt: new Date().toISOString(), + allow: threadgate.allow, // can be undefined! + hiddenReplies: threadgate.hiddenReplies || [], + } +} |