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