forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 type AppBskyFeedDefs,
3 AppBskyFeedThreadgate,
4 AtUri,
5 type BskyAgent,
6} from '@atproto/api'
7import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
8
9import {networkRetry, retry} from '#/lib/async/retry'
10import {STALE} from '#/state/queries'
11import {useGetPost} from '#/state/queries/post'
12import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
13import {
14 createThreadgateRecord,
15 mergeThreadgateRecords,
16 threadgateAllowUISettingToAllowRecordValue,
17 threadgateViewToAllowUISetting,
18} from '#/state/queries/threadgate/util'
19import {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread'
20import {useAgent} from '#/state/session'
21import {pdsAgent} from '#/state/session/agent'
22import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies'
23import * as bsky from '#/types/bsky'
24
25export * from '#/state/queries/threadgate/types'
26export * from '#/state/queries/threadgate/util'
27
28/**
29 * Must match the threadgate lexicon record definition.
30 */
31export const MAX_HIDDEN_REPLIES = 300
32
33export const threadgateRecordQueryKeyRoot = 'threadgate-record'
34export const createThreadgateRecordQueryKey = (uri: string) => [
35 threadgateRecordQueryKeyRoot,
36 uri,
37]
38
39export function useThreadgateRecordQuery({
40 postUri,
41 initialData,
42}: {
43 postUri?: string
44 initialData?: AppBskyFeedThreadgate.Record
45} = {}) {
46 const agent = useAgent()
47
48 return useQuery({
49 enabled: !!postUri,
50 queryKey: createThreadgateRecordQueryKey(postUri || ''),
51 placeholderData: initialData,
52 staleTime: STALE.MINUTES.ONE,
53 async queryFn() {
54 return getThreadgateRecord({
55 agent,
56 postUri: postUri!,
57 })
58 },
59 })
60}
61
62export const threadgateViewQueryKeyRoot = 'threadgate-view'
63export const createThreadgateViewQueryKey = (uri: string) => [
64 threadgateViewQueryKeyRoot,
65 uri,
66]
67export function useThreadgateViewQuery({
68 postUri,
69 initialData,
70}: {
71 postUri?: string
72 initialData?: AppBskyFeedDefs.ThreadgateView
73} = {}) {
74 const getPost = useGetPost()
75
76 return useQuery({
77 enabled: !!postUri,
78 queryKey: createThreadgateViewQueryKey(postUri || ''),
79 placeholderData: initialData,
80 staleTime: STALE.MINUTES.ONE,
81 async queryFn() {
82 const post = await getPost({uri: postUri!})
83 return post.threadgate ?? null
84 },
85 })
86}
87
88export async function getThreadgateRecord({
89 agent,
90 postUri,
91}: {
92 agent: BskyAgent
93 postUri: string
94}): Promise<AppBskyFeedThreadgate.Record | null> {
95 const urip = new AtUri(postUri)
96
97 if (!urip.host.startsWith('did:')) {
98 const res = await agent.resolveHandle({
99 handle: urip.host,
100 })
101 // @ts-expect-error TODO new-sdk-migration
102 urip.host = res.data.did
103 }
104
105 try {
106 const {data} = await retry(
107 2,
108 e => {
109 /*
110 * If the record doesn't exist, we want to return null instead of
111 * throwing an error. NB: This will also catch reference errors, such as
112 * a typo in the URI.
113 */
114 if (e.message.includes(`Could not locate record:`)) {
115 return false
116 }
117 return true
118 },
119 () =>
120 agent.api.com.atproto.repo.getRecord({
121 repo: urip.host,
122 collection: 'app.bsky.feed.threadgate',
123 rkey: urip.rkey,
124 }),
125 )
126
127 if (
128 data.value &&
129 bsky.validate(data.value, AppBskyFeedThreadgate.validateRecord)
130 ) {
131 return data.value
132 } else {
133 return null
134 }
135 } catch (e: any) {
136 /*
137 * If the record doesn't exist, we want to return null instead of
138 * throwing an error. NB: This will also catch reference errors, such as
139 * a typo in the URI.
140 */
141 if (e.message.includes(`Could not locate record:`)) {
142 return null
143 } else {
144 throw e
145 }
146 }
147}
148
149export async function writeThreadgateRecord({
150 agent,
151 postUri,
152 threadgate,
153}: {
154 agent: BskyAgent
155 postUri: string
156 threadgate: AppBskyFeedThreadgate.Record
157}) {
158 const postUrip = new AtUri(postUri)
159 const record = createThreadgateRecord({
160 post: postUri,
161 allow: threadgate.allow, // can/should be undefined!
162 hiddenReplies: threadgate.hiddenReplies || [],
163 })
164
165 await networkRetry(2, () =>
166 pdsAgent(agent).com.atproto.repo.putRecord({
167 repo: agent.session!.did,
168 collection: 'app.bsky.feed.threadgate',
169 rkey: postUrip.rkey,
170 record,
171 }),
172 )
173}
174
175export async function upsertThreadgate(
176 {
177 agent,
178 postUri,
179 }: {
180 agent: BskyAgent
181 postUri: string
182 },
183 callback: (
184 threadgate: AppBskyFeedThreadgate.Record | null,
185 ) => Promise<AppBskyFeedThreadgate.Record | undefined>,
186) {
187 const prev = await getThreadgateRecord({
188 agent,
189 postUri,
190 })
191 const next = await callback(prev)
192 if (!next) return
193 validateThreadgateRecordOrThrow(next)
194 await writeThreadgateRecord({
195 agent,
196 postUri,
197 threadgate: next,
198 })
199}
200
201/**
202 * Update the allow list for a threadgate record.
203 */
204export async function updateThreadgateAllow({
205 agent,
206 postUri,
207 allow,
208}: {
209 agent: BskyAgent
210 postUri: string
211 allow: ThreadgateAllowUISetting[]
212}) {
213 return upsertThreadgate({agent, postUri}, async prev => {
214 if (prev) {
215 return {
216 ...prev,
217 allow: threadgateAllowUISettingToAllowRecordValue(allow),
218 }
219 } else {
220 return createThreadgateRecord({
221 post: postUri,
222 allow: threadgateAllowUISettingToAllowRecordValue(allow),
223 })
224 }
225 })
226}
227
228export function useSetThreadgateAllowMutation() {
229 const agent = useAgent()
230 const queryClient = useQueryClient()
231 const getPost = useGetPost()
232 const updatePostThreadThreadgate = useUpdatePostThreadThreadgateQueryCache()
233
234 return useMutation({
235 mutationFn: async ({
236 postUri,
237 allow,
238 }: {
239 postUri: string
240 allow: ThreadgateAllowUISetting[]
241 }) => {
242 return upsertThreadgate({agent, postUri}, async prev => {
243 if (prev) {
244 return {
245 ...prev,
246 allow: threadgateAllowUISettingToAllowRecordValue(allow),
247 }
248 } else {
249 return createThreadgateRecord({
250 post: postUri,
251 allow: threadgateAllowUISettingToAllowRecordValue(allow),
252 })
253 }
254 })
255 },
256 async onSuccess(_, {postUri, allow}) {
257 const data = await retry<AppBskyFeedDefs.ThreadgateView | undefined>(
258 5, // 5 tries
259 _e => true,
260 async () => {
261 const post = await getPost({uri: postUri})
262 const threadgate = post.threadgate
263 if (!threadgate) {
264 throw new Error(
265 `useSetThreadgateAllowMutation: could not fetch threadgate, appview may not be ready yet`,
266 )
267 }
268 const fetchedSettings = threadgateViewToAllowUISetting(threadgate)
269 const isReady =
270 JSON.stringify(fetchedSettings) === JSON.stringify(allow)
271 if (!isReady) {
272 throw new Error(
273 `useSetThreadgateAllowMutation: appview isn't ready yet`,
274 ) // try again
275 }
276 return threadgate
277 },
278 1e3, // 1s delay between tries
279 ).catch(() => {})
280
281 if (data) updatePostThreadThreadgate(data)
282
283 queryClient.invalidateQueries({
284 queryKey: [threadgateRecordQueryKeyRoot],
285 })
286 queryClient.invalidateQueries({
287 queryKey: [threadgateViewQueryKeyRoot],
288 })
289 },
290 })
291}
292
293export function useToggleReplyVisibilityMutation() {
294 const agent = useAgent()
295 const queryClient = useQueryClient()
296 const hiddenReplies = useThreadgateHiddenReplyUrisAPI()
297
298 return useMutation({
299 mutationFn: async ({
300 postUri,
301 replyUri,
302 action,
303 }: {
304 postUri: string
305 replyUri: string
306 action: 'hide' | 'show'
307 }) => {
308 if (action === 'hide') {
309 hiddenReplies.addHiddenReplyUri(replyUri)
310 } else if (action === 'show') {
311 hiddenReplies.removeHiddenReplyUri(replyUri)
312 }
313
314 await upsertThreadgate({agent, postUri}, async prev => {
315 if (prev) {
316 if (action === 'hide') {
317 return mergeThreadgateRecords(prev, {
318 hiddenReplies: [replyUri],
319 })
320 } else if (action === 'show') {
321 return {
322 ...prev,
323 hiddenReplies:
324 prev.hiddenReplies?.filter(uri => uri !== replyUri) || [],
325 }
326 }
327 } else {
328 if (action === 'hide') {
329 return createThreadgateRecord({
330 post: postUri,
331 hiddenReplies: [replyUri],
332 })
333 }
334 }
335 })
336 },
337 onSuccess() {
338 queryClient.invalidateQueries({
339 queryKey: [threadgateRecordQueryKeyRoot],
340 })
341 },
342 onError(_, {replyUri, action}) {
343 if (action === 'hide') {
344 hiddenReplies.removeHiddenReplyUri(replyUri)
345 } else if (action === 'show') {
346 hiddenReplies.addHiddenReplyUri(replyUri)
347 }
348 },
349 })
350}
351
352export class MaxHiddenRepliesError extends Error {
353 constructor(message?: string) {
354 super(message || 'Maximum number of hidden replies reached')
355 this.name = 'MaxHiddenRepliesError'
356 }
357}
358
359export class InvalidInteractionSettingsError extends Error {
360 constructor(message?: string) {
361 super(message || 'Invalid interaction settings')
362 this.name = 'InvalidInteractionSettingsError'
363 }
364}
365
366export function validateThreadgateRecordOrThrow(
367 record: AppBskyFeedThreadgate.Record,
368) {
369 const result = AppBskyFeedThreadgate.validateRecord(record)
370
371 if (result.success) {
372 if ((result.value.hiddenReplies?.length ?? 0) > MAX_HIDDEN_REPLIES) {
373 throw new MaxHiddenRepliesError()
374 }
375 } else {
376 throw new InvalidInteractionSettingsError()
377 }
378}