forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
2import {
3 useInfiniteQuery,
4 useMutation,
5 useQueryClient,
6} from '@tanstack/react-query'
7
8import {isNetworkError} from '#/lib/strings/errors'
9import {useAgent} from '#/state/session'
10import {type ComposerState} from '#/view/com/composer/state/composer'
11import {useAnalytics} from '#/analytics'
12import {getDeviceId} from '#/analytics/identifiers'
13import {composerStateToDraft, draftViewToSummary} from './api'
14import {logger} from './logger'
15import * as storage from './storage'
16
17const DRAFTS_QUERY_KEY = ['drafts']
18
19/**
20 * Hook to list all drafts for the current account
21 */
22export function useDraftsQuery() {
23 const agent = useAgent()
24 const ax = useAnalytics()
25
26 return useInfiniteQuery({
27 queryKey: DRAFTS_QUERY_KEY,
28 queryFn: async ({pageParam}) => {
29 // Ensure media cache is populated before checking which media exists
30 await storage.ensureMediaCachePopulated()
31 const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
32 return {
33 cursor: res.data.cursor,
34 drafts: res.data.drafts.map(view =>
35 draftViewToSummary({
36 view,
37 analytics: ax,
38 }),
39 ),
40 }
41 },
42 initialPageParam: undefined as string | undefined,
43 getNextPageParam: page => page.cursor || undefined,
44 })
45}
46
47/**
48 * Load a draft's local media for editing.
49 * Takes the full Draft object (from DraftSummary) to avoid re-fetching.
50 */
51export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
52 loadedMedia: Map<string, string>
53}> {
54 // Load local media files
55 const loadedMedia = new Map<string, string>()
56
57 // can't load media from another device
58 if (draft.deviceId && draft.deviceId !== getDeviceId()) {
59 return {loadedMedia}
60 }
61
62 for (const post of draft.posts) {
63 // Load images
64 if (post.embedImages) {
65 for (const img of post.embedImages) {
66 try {
67 const url = await storage.loadMediaFromLocal(img.localRef.path)
68 loadedMedia.set(img.localRef.path, url)
69 } catch (e: any) {
70 logger.error('Failed to load draft image', {
71 path: img.localRef.path,
72 safeMessage: e.message,
73 })
74 }
75 }
76 }
77 // Load videos
78 if (post.embedVideos) {
79 for (const vid of post.embedVideos) {
80 try {
81 const url = await storage.loadMediaFromLocal(vid.localRef.path)
82 loadedMedia.set(vid.localRef.path, url)
83 } catch (e: any) {
84 logger.error('Failed to load draft video', {
85 path: vid.localRef.path,
86 safeMessage: e.message,
87 })
88 }
89 }
90 }
91 }
92
93 return {loadedMedia}
94}
95
96/**
97 * Hook to save a draft.
98 *
99 * IMPORTANT: Network operations happen first in mutationFn.
100 * Local storage operations (save new media, delete orphaned media) happen in onSuccess.
101 * This ensures we don't lose data if the network request fails.
102 */
103export function useSaveDraftMutation() {
104 const agent = useAgent()
105 const queryClient = useQueryClient()
106
107 return useMutation({
108 mutationFn: async ({
109 composerState,
110 existingDraftId,
111 }: {
112 composerState: ComposerState
113 existingDraftId?: string
114 }): Promise<{
115 draftId: string
116 localRefPaths: Map<string, string>
117 originalLocalRefs: Set<string> | undefined
118 }> => {
119 // Convert composer state to server draft format
120 const {draft, localRefPaths} = await composerStateToDraft(composerState)
121
122 logger.debug('saving draft', {
123 existingDraftId,
124 localRefPathCount: localRefPaths.size,
125 originalLocalRefCount: composerState.originalLocalRefs?.size ?? 0,
126 })
127
128 // 1. NETWORK FIRST - Update/create server draft
129 let draftId: string
130 if (existingDraftId) {
131 // Update existing draft
132 logger.debug('updating existing draft on server', {
133 draftId: existingDraftId,
134 })
135 await agent.app.bsky.draft.updateDraft({
136 draft: {
137 id: existingDraftId,
138 draft,
139 },
140 })
141 draftId = existingDraftId
142 } else {
143 // Create new draft
144 logger.debug('creating new draft on server')
145 const res = await agent.app.bsky.draft.createDraft({draft})
146 draftId = res.data.id
147 logger.debug('created new draft', {draftId})
148 }
149
150 // Return data needed for onSuccess
151 return {
152 draftId,
153 localRefPaths,
154 originalLocalRefs: composerState.originalLocalRefs,
155 }
156 },
157 onSuccess: async ({draftId, localRefPaths, originalLocalRefs}) => {
158 // 2. LOCAL STORAGE ONLY AFTER NETWORK SUCCEEDS
159 logger.debug('network save succeeded, processing local storage', {
160 draftId,
161 })
162
163 // Save new/changed media files
164 for (const [localRefPath, sourcePath] of localRefPaths) {
165 // Only save if this media doesn't already exist (reusing localRefPath)
166 if (!storage.mediaExists(localRefPath)) {
167 logger.debug('saving new media file', {localRefPath})
168 await storage.saveMediaToLocal(localRefPath, sourcePath)
169 } else {
170 logger.debug('skipping existing media file', {localRefPath})
171 }
172 }
173
174 // Delete orphaned media (old refs not in new)
175 if (originalLocalRefs) {
176 const newLocalRefs = new Set(localRefPaths.keys())
177 for (const oldRef of originalLocalRefs) {
178 if (!newLocalRefs.has(oldRef)) {
179 logger.debug('deleting orphaned media file', {
180 localRefPath: oldRef,
181 })
182 await storage.deleteMediaFromLocal(oldRef)
183 }
184 }
185 }
186
187 await queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
188 },
189 onError: error => {
190 // Check for draft limit error
191 if (error instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) {
192 logger.error('Draft limit reached', {safeMessage: error.message})
193 // Error will be handled by caller
194 } else if (!isNetworkError(error)) {
195 logger.error('Could not create draft (reason unknown)', {
196 safeMessage: error.message,
197 })
198 }
199 },
200 })
201}
202
203/**
204 * Hook to delete a draft.
205 * Takes the full draft data to avoid re-fetching for media cleanup.
206 */
207export function useDeleteDraftMutation() {
208 const agent = useAgent()
209 const queryClient = useQueryClient()
210
211 return useMutation({
212 mutationFn: async ({
213 draftId,
214 }: {
215 draftId: string
216 draft: AppBskyDraftDefs.Draft
217 }) => {
218 // Delete from server first - if this fails, we keep local media for retry
219 await agent.app.bsky.draft.deleteDraft({id: draftId})
220 },
221 onSuccess: async (_, {draft}) => {
222 // Only delete local media after server deletion succeeds
223 for (const post of draft.posts) {
224 if (post.embedImages) {
225 for (const img of post.embedImages) {
226 await storage.deleteMediaFromLocal(img.localRef.path)
227 }
228 }
229 if (post.embedVideos) {
230 for (const vid of post.embedVideos) {
231 await storage.deleteMediaFromLocal(vid.localRef.path)
232 }
233 }
234 }
235 queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
236 },
237 })
238}
239
240/**
241 * Hook to clean up a draft after it has been published.
242 * Deletes the draft from server and all associated local media.
243 * Takes draftId and originalLocalRefs from composer state.
244 */
245export function useCleanupPublishedDraftMutation() {
246 const agent = useAgent()
247 const queryClient = useQueryClient()
248
249 return useMutation({
250 mutationFn: async ({
251 draftId,
252 originalLocalRefs,
253 }: {
254 draftId: string
255 originalLocalRefs: Set<string>
256 }) => {
257 logger.debug('cleaning up published draft', {
258 draftId,
259 mediaFileCount: originalLocalRefs.size,
260 })
261 // Delete from server first
262 await agent.app.bsky.draft.deleteDraft({id: draftId})
263 logger.debug('deleted draft from server', {draftId})
264 },
265 onSuccess: async (_, {originalLocalRefs}) => {
266 // Delete all local media files for this draft
267 for (const localRef of originalLocalRefs) {
268 logger.debug('deleting media file after publish', {
269 localRefPath: localRef,
270 })
271 await storage.deleteMediaFromLocal(localRef)
272 }
273 queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
274 logger.debug('cleanup after publish complete')
275 },
276 onError: error => {
277 // Log but don't throw - the post was already published successfully
278 logger.warn('Failed to clean up published draft', {
279 safeMessage: error instanceof Error ? error.message : String(error),
280 })
281 },
282 })
283}