my fork of the bluesky client
at main 356 lines 8.7 kB view raw
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}