forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 AppBskyFeedDefs,
3 AppBskyGraphDefs,
4 type AppBskyGraphGetStarterPack,
5 AppBskyGraphStarterpack,
6 type AppBskyRichtextFacet,
7 AtUri,
8 type BskyAgent,
9 RichText,
10} from '@atproto/api'
11import {
12 type QueryClient,
13 useMutation,
14 useQuery,
15 useQueryClient,
16} from '@tanstack/react-query'
17import chunk from 'lodash.chunk'
18
19import {until} from '#/lib/async/until'
20import {createStarterPackList} from '#/lib/generate-starterpack'
21import {
22 createStarterPackUri,
23 httpStarterPackUriToAtUri,
24 parseStarterPackUri,
25} from '#/lib/strings/starter-pack'
26import {invalidateActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
27import {STALE} from '#/state/queries/index'
28import {invalidateListMembersQuery} from '#/state/queries/list-members'
29import {useAgent} from '#/state/session'
30import {pdsAgent} from '#/state/session/agent'
31import * as bsky from '#/types/bsky'
32
33const RQKEY_ROOT = 'starter-pack'
34const RQKEY = ({
35 uri,
36 did,
37 rkey,
38}: {
39 uri?: string
40 did?: string
41 rkey?: string
42}) => {
43 if (uri?.startsWith('https://') || uri?.startsWith('at://')) {
44 const parsed = parseStarterPackUri(uri)
45 return [RQKEY_ROOT, parsed?.name, parsed?.rkey]
46 } else {
47 return [RQKEY_ROOT, did, rkey]
48 }
49}
50
51export function useStarterPackQuery({
52 uri,
53 did,
54 rkey,
55}: {
56 uri?: string
57 did?: string
58 rkey?: string
59}) {
60 const agent = useAgent()
61
62 return useQuery<AppBskyGraphDefs.StarterPackView>({
63 queryKey: RQKEY(uri ? {uri} : {did, rkey}),
64 queryFn: async () => {
65 if (!uri) {
66 uri = `at://${did}/app.bsky.graph.starterpack/${rkey}`
67 } else if (uri && !uri.startsWith('at://')) {
68 uri = httpStarterPackUriToAtUri(uri) as string
69 }
70
71 const res = await agent.app.bsky.graph.getStarterPack({
72 starterPack: uri,
73 })
74 return res.data.starterPack
75 },
76 enabled: Boolean(uri) || Boolean(did && rkey),
77 staleTime: STALE.MINUTES.FIVE,
78 })
79}
80
81export async function invalidateStarterPack({
82 queryClient,
83 did,
84 rkey,
85}: {
86 queryClient: QueryClient
87 did: string
88 rkey: string
89}) {
90 await queryClient.invalidateQueries({queryKey: RQKEY({did, rkey})})
91}
92
93interface UseCreateStarterPackMutationParams {
94 name: string
95 description?: string
96 profiles: bsky.profile.AnyProfileView[]
97 feeds?: AppBskyFeedDefs.GeneratorView[]
98}
99
100export function useCreateStarterPackMutation({
101 onSuccess,
102 onError,
103}: {
104 onSuccess: (data: {uri: string; cid: string}) => void
105 onError: (e: Error) => void
106}) {
107 const queryClient = useQueryClient()
108 const agent = useAgent()
109
110 return useMutation<
111 {uri: string; cid: string},
112 Error,
113 UseCreateStarterPackMutationParams
114 >({
115 mutationFn: async ({name, description, feeds, profiles}) => {
116 let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined
117 if (description) {
118 const rt = new RichText({text: description})
119 await rt.detectFacets(agent)
120 descriptionFacets = rt.facets
121 }
122
123 let listRes
124 listRes = await createStarterPackList({
125 name,
126 description,
127 profiles,
128 descriptionFacets,
129 agent,
130 })
131
132 return await agent.app.bsky.graph.starterpack.create(
133 {
134 repo: agent.assertDid,
135 },
136 {
137 name,
138 description,
139 descriptionFacets,
140 list: listRes?.uri,
141 feeds: feeds?.map(f => ({uri: f.uri})),
142 createdAt: new Date().toISOString(),
143 },
144 )
145 },
146 onSuccess: async data => {
147 await whenAppViewReady(agent, data.uri, v => {
148 return typeof v?.data.starterPack.uri === 'string'
149 })
150 await invalidateActorStarterPacksQuery({
151 queryClient,
152 did: agent.session!.did,
153 })
154 onSuccess(data)
155 },
156 onError: async error => {
157 onError(error)
158 },
159 })
160}
161
162export function useEditStarterPackMutation({
163 onSuccess,
164 onError,
165}: {
166 onSuccess: () => void
167 onError: (error: Error) => void
168}) {
169 const queryClient = useQueryClient()
170 const agent = useAgent()
171
172 return useMutation<
173 void,
174 Error,
175 UseCreateStarterPackMutationParams & {
176 currentStarterPack: AppBskyGraphDefs.StarterPackView
177 currentListItems: AppBskyGraphDefs.ListItemView[]
178 }
179 >({
180 mutationFn: async ({
181 name,
182 description,
183 feeds,
184 profiles,
185 currentStarterPack,
186 currentListItems,
187 }) => {
188 let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined
189 if (description) {
190 const rt = new RichText({text: description})
191 await rt.detectFacets(agent)
192 descriptionFacets = rt.facets
193 }
194
195 if (!AppBskyGraphStarterpack.isRecord(currentStarterPack.record)) {
196 throw new Error('Invalid starter pack')
197 }
198
199 const removedItems = currentListItems.filter(
200 i =>
201 i.subject.did !== agent.session?.did &&
202 !profiles.find(p => p.did === i.subject.did && p.did),
203 )
204 if (removedItems.length !== 0) {
205 const chunks = chunk(removedItems, 50)
206 for (const chunk of chunks) {
207 await pdsAgent(agent).com.atproto.repo.applyWrites({
208 repo: agent.session!.did,
209 writes: chunk.map(i => ({
210 $type: 'com.atproto.repo.applyWrites#delete',
211 collection: 'app.bsky.graph.listitem',
212 rkey: new AtUri(i.uri).rkey,
213 })),
214 })
215 }
216 }
217
218 const addedProfiles = profiles.filter(
219 p => !currentListItems.find(i => i.subject.did === p.did),
220 )
221 if (addedProfiles.length > 0) {
222 const chunks = chunk(addedProfiles, 50)
223 for (const chunk of chunks) {
224 await pdsAgent(agent).com.atproto.repo.applyWrites({
225 repo: agent.session!.did,
226 writes: chunk.map(p => ({
227 $type: 'com.atproto.repo.applyWrites#create',
228 collection: 'app.bsky.graph.listitem',
229 value: {
230 $type: 'app.bsky.graph.listitem',
231 subject: p.did,
232 list: currentStarterPack.list?.uri,
233 createdAt: new Date().toISOString(),
234 },
235 })),
236 })
237 }
238 }
239
240 const rkey = parseStarterPackUri(currentStarterPack.uri)!.rkey
241 await pdsAgent(agent).com.atproto.repo.putRecord({
242 repo: agent.session!.did,
243 collection: 'app.bsky.graph.starterpack',
244 rkey,
245 record: {
246 name,
247 description,
248 descriptionFacets,
249 list: currentStarterPack.list?.uri,
250 feeds,
251 createdAt: currentStarterPack.record.createdAt,
252 updatedAt: new Date().toISOString(),
253 },
254 })
255 },
256 onSuccess: async (_, {currentStarterPack}) => {
257 const parsed = parseStarterPackUri(currentStarterPack.uri)
258 await whenAppViewReady(agent, currentStarterPack.uri, v => {
259 return currentStarterPack.cid !== v?.data.starterPack.cid
260 })
261 await invalidateActorStarterPacksQuery({
262 queryClient,
263 did: agent.session!.did,
264 })
265 if (currentStarterPack.list) {
266 await invalidateListMembersQuery({
267 queryClient,
268 uri: currentStarterPack.list.uri,
269 })
270 }
271 await invalidateStarterPack({
272 queryClient,
273 did: agent.session!.did,
274 rkey: parsed!.rkey,
275 })
276 onSuccess()
277 },
278 onError: error => {
279 onError(error)
280 },
281 })
282}
283
284export function useDeleteStarterPackMutation({
285 onSuccess,
286 onError,
287}: {
288 onSuccess: () => void
289 onError: (error: Error) => void
290}) {
291 const agent = useAgent()
292 const queryClient = useQueryClient()
293
294 return useMutation({
295 mutationFn: async ({listUri, rkey}: {listUri?: string; rkey: string}) => {
296 if (!agent.session) {
297 throw new Error(`Requires signed in user`)
298 }
299
300 if (listUri) {
301 await agent.app.bsky.graph.list.delete({
302 repo: agent.session.did,
303 rkey: new AtUri(listUri).rkey,
304 })
305 }
306 await agent.app.bsky.graph.starterpack.delete({
307 repo: agent.session.did,
308 rkey,
309 })
310 },
311 onSuccess: async (_, {listUri, rkey}) => {
312 const uri = createStarterPackUri({
313 did: agent.session!.did,
314 rkey,
315 })
316
317 if (uri) {
318 await whenAppViewReady(agent, uri, v => {
319 return Boolean(v?.data?.starterPack) === false
320 })
321 }
322
323 if (listUri) {
324 await invalidateListMembersQuery({queryClient, uri: listUri})
325 }
326 await invalidateActorStarterPacksQuery({
327 queryClient,
328 did: agent.session!.did,
329 })
330 await invalidateStarterPack({
331 queryClient,
332 did: agent.session!.did,
333 rkey,
334 })
335 onSuccess()
336 },
337 onError: error => {
338 onError(error)
339 },
340 })
341}
342
343async function whenAppViewReady(
344 agent: BskyAgent,
345 uri: string,
346 fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean,
347) {
348 await until(
349 5, // 5 tries
350 1e3, // 1s delay between tries
351 fn,
352 () => agent.app.bsky.graph.getStarterPack({starterPack: uri}),
353 )
354}
355
356export function precacheStarterPack(
357 queryClient: QueryClient,
358 starterPack:
359 | AppBskyGraphDefs.StarterPackViewBasic
360 | AppBskyGraphDefs.StarterPackView,
361) {
362 if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
363 return
364 }
365
366 let starterPackView: AppBskyGraphDefs.StarterPackView | undefined
367 if (AppBskyGraphDefs.isStarterPackView(starterPack)) {
368 starterPackView = starterPack
369 } else if (
370 AppBskyGraphDefs.isStarterPackViewBasic(starterPack) &&
371 bsky.validate(starterPack.record, AppBskyGraphStarterpack.validateRecord)
372 ) {
373 let feeds: AppBskyFeedDefs.GeneratorView[] | undefined
374 if (starterPack.record.feeds) {
375 feeds = []
376 for (const feed of starterPack.record.feeds) {
377 // note: types are wrong? claims to be `FeedItem`, but we actually
378 // get un$typed `GeneratorView` objects here -sfn
379 if (bsky.validate(feed, AppBskyFeedDefs.validateGeneratorView)) {
380 feeds.push(feed)
381 }
382 }
383 }
384
385 const listView: AppBskyGraphDefs.ListViewBasic = {
386 uri: starterPack.record.list,
387 // This will be populated once the data from server is fetched
388 cid: '',
389 name: starterPack.record.name,
390 purpose: 'app.bsky.graph.defs#referencelist',
391 }
392 starterPackView = {
393 ...starterPack,
394 $type: 'app.bsky.graph.defs#starterPackView',
395 list: listView,
396 feeds,
397 }
398 }
399
400 if (starterPackView) {
401 queryClient.setQueryData(RQKEY({uri: starterPack.uri}), starterPackView)
402 }
403}