my fork of the bluesky client
1import {Image as RNImage} from 'react-native-image-crop-picker'
2import {
3 AppBskyGraphDefs,
4 AppBskyGraphGetList,
5 AppBskyGraphList,
6 AtUri,
7 BskyAgent,
8 Facet,
9} from '@atproto/api'
10import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
11import chunk from 'lodash.chunk'
12
13import {uploadBlob} from '#/lib/api'
14import {until} from '#/lib/async/until'
15import {STALE} from '#/state/queries'
16import {useAgent, useSession} from '../session'
17import {invalidate as invalidateMyLists} from './my-lists'
18import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
19
20export const RQKEY_ROOT = 'list'
21export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
22
23export function useListQuery(uri?: string) {
24 const agent = useAgent()
25 return useQuery<AppBskyGraphDefs.ListView, Error>({
26 staleTime: STALE.MINUTES.ONE,
27 queryKey: RQKEY(uri || ''),
28 async queryFn() {
29 if (!uri) {
30 throw new Error('URI not provided')
31 }
32 const res = await agent.app.bsky.graph.getList({
33 list: uri,
34 limit: 1,
35 })
36 return res.data.list
37 },
38 enabled: !!uri,
39 })
40}
41
42export interface ListCreateMutateParams {
43 purpose: string
44 name: string
45 description: string
46 descriptionFacets: Facet[] | undefined
47 avatar: RNImage | null | undefined
48}
49export function useListCreateMutation() {
50 const {currentAccount} = useSession()
51 const queryClient = useQueryClient()
52 const agent = useAgent()
53 return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
54 {
55 async mutationFn({
56 purpose,
57 name,
58 description,
59 descriptionFacets,
60 avatar,
61 }) {
62 if (!currentAccount) {
63 throw new Error('Not logged in')
64 }
65 if (
66 purpose !== 'app.bsky.graph.defs#curatelist' &&
67 purpose !== 'app.bsky.graph.defs#modlist'
68 ) {
69 throw new Error('Invalid list purpose: must be curatelist or modlist')
70 }
71 const record: AppBskyGraphList.Record = {
72 purpose,
73 name,
74 description,
75 descriptionFacets,
76 avatar: undefined,
77 createdAt: new Date().toISOString(),
78 }
79 if (avatar) {
80 const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
81 record.avatar = blobRes.data.blob
82 }
83 const res = await agent.app.bsky.graph.list.create(
84 {
85 repo: currentAccount.did,
86 },
87 record,
88 )
89
90 // wait for the appview to update
91 await whenAppViewReady(
92 agent,
93 res.uri,
94 (v: AppBskyGraphGetList.Response) => {
95 return typeof v?.data?.list.uri === 'string'
96 },
97 )
98 return res
99 },
100 onSuccess() {
101 invalidateMyLists(queryClient)
102 queryClient.invalidateQueries({
103 queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
104 })
105 },
106 },
107 )
108}
109
110export interface ListMetadataMutateParams {
111 uri: string
112 name: string
113 description: string
114 descriptionFacets: Facet[] | undefined
115 avatar: RNImage | null | undefined
116}
117export function useListMetadataMutation() {
118 const {currentAccount} = useSession()
119 const agent = useAgent()
120 const queryClient = useQueryClient()
121 return useMutation<
122 {uri: string; cid: string},
123 Error,
124 ListMetadataMutateParams
125 >({
126 async mutationFn({uri, name, description, descriptionFacets, avatar}) {
127 const {hostname, rkey} = new AtUri(uri)
128 if (!currentAccount) {
129 throw new Error('Not logged in')
130 }
131 if (currentAccount.did !== hostname) {
132 throw new Error('You do not own this list')
133 }
134
135 // get the current record
136 const {value: record} = await agent.app.bsky.graph.list.get({
137 repo: currentAccount.did,
138 rkey,
139 })
140
141 // update the fields
142 record.name = name
143 record.description = description
144 record.descriptionFacets = descriptionFacets
145 if (avatar) {
146 const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
147 record.avatar = blobRes.data.blob
148 } else if (avatar === null) {
149 record.avatar = undefined
150 }
151 const res = (
152 await agent.com.atproto.repo.putRecord({
153 repo: currentAccount.did,
154 collection: 'app.bsky.graph.list',
155 rkey,
156 record,
157 })
158 ).data
159
160 // wait for the appview to update
161 await whenAppViewReady(
162 agent,
163 res.uri,
164 (v: AppBskyGraphGetList.Response) => {
165 const list = v.data.list
166 return (
167 list.name === record.name && list.description === record.description
168 )
169 },
170 )
171 return res
172 },
173 onSuccess(data, variables) {
174 invalidateMyLists(queryClient)
175 queryClient.invalidateQueries({
176 queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
177 })
178 queryClient.invalidateQueries({
179 queryKey: RQKEY(variables.uri),
180 })
181 },
182 })
183}
184
185export function useListDeleteMutation() {
186 const {currentAccount} = useSession()
187 const agent = useAgent()
188 const queryClient = useQueryClient()
189 return useMutation<void, Error, {uri: string}>({
190 mutationFn: async ({uri}) => {
191 if (!currentAccount) {
192 return
193 }
194 // fetch all the listitem records that belong to this list
195 let cursor
196 let listitemRecordUris: string[] = []
197 for (let i = 0; i < 100; i++) {
198 const res = await agent.app.bsky.graph.listitem.list({
199 repo: currentAccount.did,
200 cursor,
201 limit: 100,
202 })
203 listitemRecordUris = listitemRecordUris.concat(
204 res.records
205 .filter(record => record.value.list === uri)
206 .map(record => record.uri),
207 )
208 cursor = res.cursor
209 if (!cursor) {
210 break
211 }
212 }
213
214 // batch delete the list and listitem records
215 const createDel = (uri: string) => {
216 const urip = new AtUri(uri)
217 return {
218 $type: 'com.atproto.repo.applyWrites#delete',
219 collection: urip.collection,
220 rkey: urip.rkey,
221 }
222 }
223 const writes = listitemRecordUris
224 .map(uri => createDel(uri))
225 .concat([createDel(uri)])
226
227 // apply in chunks
228 for (const writesChunk of chunk(writes, 10)) {
229 await agent.com.atproto.repo.applyWrites({
230 repo: currentAccount.did,
231 writes: writesChunk,
232 })
233 }
234
235 // wait for the appview to update
236 await whenAppViewReady(agent, uri, (v: AppBskyGraphGetList.Response) => {
237 return !v?.success
238 })
239 },
240 onSuccess() {
241 invalidateMyLists(queryClient)
242 queryClient.invalidateQueries({
243 queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
244 })
245 // TODO!! /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
246 },
247 })
248}
249
250export function useListMuteMutation() {
251 const queryClient = useQueryClient()
252 const agent = useAgent()
253 return useMutation<void, Error, {uri: string; mute: boolean}>({
254 mutationFn: async ({uri, mute}) => {
255 if (mute) {
256 await agent.muteModList(uri)
257 } else {
258 await agent.unmuteModList(uri)
259 }
260
261 await whenAppViewReady(agent, uri, (v: AppBskyGraphGetList.Response) => {
262 return Boolean(v?.data.list.viewer?.muted) === mute
263 })
264 },
265 onSuccess(data, variables) {
266 queryClient.invalidateQueries({
267 queryKey: RQKEY(variables.uri),
268 })
269 },
270 })
271}
272
273export function useListBlockMutation() {
274 const queryClient = useQueryClient()
275 const agent = useAgent()
276 return useMutation<void, Error, {uri: string; block: boolean}>({
277 mutationFn: async ({uri, block}) => {
278 if (block) {
279 await agent.blockModList(uri)
280 } else {
281 await agent.unblockModList(uri)
282 }
283
284 await whenAppViewReady(agent, uri, (v: AppBskyGraphGetList.Response) => {
285 return block
286 ? typeof v?.data.list.viewer?.blocked === 'string'
287 : !v?.data.list.viewer?.blocked
288 })
289 },
290 onSuccess(data, variables) {
291 queryClient.invalidateQueries({
292 queryKey: RQKEY(variables.uri),
293 })
294 },
295 })
296}
297
298async function whenAppViewReady(
299 agent: BskyAgent,
300 uri: string,
301 fn: (res: AppBskyGraphGetList.Response) => boolean,
302) {
303 await until(
304 5, // 5 tries
305 1e3, // 1s delay between tries
306 fn,
307 () =>
308 agent.app.bsky.graph.getList({
309 list: uri,
310 limit: 1,
311 }),
312 )
313}