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