Bluesky app fork with some witchin' additions 馃挮
at main 283 lines 8.8 kB view raw
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}