Detached QPs and hidden replies (#4878)
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
parent
56ab5e177f
commit
6616a6467e
41 changed files with 2584 additions and 622 deletions
20
src/state/cache/post-shadow.ts
vendored
20
src/state/cache/post-shadow.ts
vendored
|
@ -1,5 +1,9 @@
|
|||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
} from '@atproto/api'
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
|
@ -16,6 +20,7 @@ export interface PostShadow {
|
|||
likeUri: string | undefined
|
||||
repostUri: string | undefined
|
||||
isDeleted: boolean
|
||||
embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
|
||||
}
|
||||
|
||||
export const POST_TOMBSTONE = Symbol('PostTombstone')
|
||||
|
@ -87,8 +92,21 @@ function mergeShadow(
|
|||
repostCount = Math.max(0, repostCount)
|
||||
}
|
||||
|
||||
let embed: typeof post.embed
|
||||
if ('embed' in shadow) {
|
||||
if (
|
||||
(AppBskyEmbedRecord.isView(post.embed) &&
|
||||
AppBskyEmbedRecord.isView(shadow.embed)) ||
|
||||
(AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
||||
AppBskyEmbedRecordWithMedia.isView(shadow.embed))
|
||||
) {
|
||||
embed = shadow.embed
|
||||
}
|
||||
}
|
||||
|
||||
return castAsShadow({
|
||||
...post,
|
||||
embed: embed || post.embed,
|
||||
likeCount: likeCount,
|
||||
repostCount: repostCount,
|
||||
viewer: {
|
||||
|
|
|
@ -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
|
||||
|
||||
return data
|
||||
},
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'] &
|
||||
|
|
295
src/state/queries/postgate/index.ts
Normal file
295
src/state/queries/postgate/index.ts
Normal file
|
@ -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'}],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
196
src/state/queries/postgate/util.ts
Normal file
196
src/state/queries/postgate/util.ts
Normal file
|
@ -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'},
|
||||
}
|
|
@ -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
|
||||
}
|
358
src/state/queries/threadgate/index.ts
Normal file
358
src/state/queries/threadgate/index.ts
Normal file
|
@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
6
src/state/queries/threadgate/types.ts
Normal file
6
src/state/queries/threadgate/types.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export type ThreadgateAllowUISetting =
|
||||
| {type: 'everybody'}
|
||||
| {type: 'nobody'}
|
||||
| {type: 'mention'}
|
||||
| {type: 'following'}
|
||||
| {type: 'list'; list: unknown}
|
141
src/state/queries/threadgate/util.ts
Normal file
141
src/state/queries/threadgate/util.ts
Normal file
|
@ -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 || [],
|
||||
}
|
||||
}
|
69
src/state/threadgate-hidden-replies.tsx
Normal file
69
src/state/threadgate-hidden-replies.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import React from 'react'
|
||||
|
||||
type StateContext = {
|
||||
uris: Set<string>
|
||||
recentlyUnhiddenUris: Set<string>
|
||||
}
|
||||
type ApiContext = {
|
||||
addHiddenReplyUri: (uri: string) => void
|
||||
removeHiddenReplyUri: (uri: string) => void
|
||||
}
|
||||
|
||||
const StateContext = React.createContext<StateContext>({
|
||||
uris: new Set(),
|
||||
recentlyUnhiddenUris: new Set(),
|
||||
})
|
||||
|
||||
const ApiContext = React.createContext<ApiContext>({
|
||||
addHiddenReplyUri: () => {},
|
||||
removeHiddenReplyUri: () => {},
|
||||
})
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [uris, setHiddenReplyUris] = React.useState<Set<string>>(new Set())
|
||||
const [recentlyUnhiddenUris, setRecentlyUnhiddenUris] = React.useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
|
||||
const stateCtx = React.useMemo(
|
||||
() => ({
|
||||
uris,
|
||||
recentlyUnhiddenUris,
|
||||
}),
|
||||
[uris, recentlyUnhiddenUris],
|
||||
)
|
||||
|
||||
const apiCtx = React.useMemo(
|
||||
() => ({
|
||||
addHiddenReplyUri(uri: string) {
|
||||
setHiddenReplyUris(prev => new Set(prev.add(uri)))
|
||||
setRecentlyUnhiddenUris(prev => {
|
||||
prev.delete(uri)
|
||||
return new Set(prev)
|
||||
})
|
||||
},
|
||||
removeHiddenReplyUri(uri: string) {
|
||||
setHiddenReplyUris(prev => {
|
||||
prev.delete(uri)
|
||||
return new Set(prev)
|
||||
})
|
||||
setRecentlyUnhiddenUris(prev => new Set(prev.add(uri)))
|
||||
},
|
||||
}),
|
||||
[setHiddenReplyUris],
|
||||
)
|
||||
|
||||
return (
|
||||
<ApiContext.Provider value={apiCtx}>
|
||||
<StateContext.Provider value={stateCtx}>{children}</StateContext.Provider>
|
||||
</ApiContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useThreadgateHiddenReplyUris() {
|
||||
return React.useContext(StateContext)
|
||||
}
|
||||
|
||||
export function useThreadgateHiddenReplyUrisAPI() {
|
||||
return React.useContext(ApiContext)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue