Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 377 lines 9.7 kB view raw
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}