Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 8c3553cd66ad07ef8c8c4e760b495cf6ce08cc8d 378 lines 9.8 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 {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}