Fix NaN aspectRatio when rehydrating GIFs from drafts by: 1. Adding validation in parseTenorGif to reject invalid dimensions (NaN, zero, or negative values) 2. Adding defensive checks in GifEmbed to fallback to 1:1 aspect ratio if dimensions are invalid 3. Adding defensive checks in composer ExternalEmbedGif to fallback to 16:9 if gif.media_formats.gif.dims is missing or invalid Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
9.1 KiB
Plan: Migrate Drafts from Local Storage to Remote API
Overview
Replace the local-only draft storage with the new agent.app.bsky.draft.* server API. The server API provides getDrafts, createDraft, updateDraft, and deleteDraft endpoints.
Key Decisions (Confirmed)
- Server-only storage - No hybrid/offline support for now
- Media via localRef - Images and compressed videos stored locally, referenced by filepath in
localRef. When fetching drafts, check if local file exists. If missing (different device), show a note in the list and ignore that media when editing. - No reply drafts - Hide the drafts button entirely when replying
- GIFs as external embeds - Store URL with dimensions in
DraftEmbedExternal, rehydrate from URL pattern (like feed embeds). No local file needed. - Video compression flow unchanged - Video compresses immediately, then save compressed file locally and reference via localRef
Phase 1: Update Schema & Types
1.1 Create type converters (src/state/drafts/api.ts)
Map between local StoredDraft types and server Draft types:
// Server types from @atproto/api
import {
AppBskyDraftDefs,
AppBskyDraftCreateDraft,
AppBskyDraftUpdateDraft,
} from '@atproto/api'
// Convert ComposerState → server Draft
function composerStateToDraft(state: ComposerState, replyTo?: ReplyTo): AppBskyDraftDefs.Draft
// Convert server DraftView → local display format
function draftViewToSummary(view: AppBskyDraftDefs.DraftView): DraftSummary
// Convert server Draft → ComposerState for restoration
function draftToComposerState(draft: AppBskyDraftDefs.Draft): Partial<ComposerState>
1.2 Update schema.ts
- Remove
syncStatusandserverDraftId(no longer needed) - Keep
DraftSummaryandDraftPostDisplayfor UI (derived from server response) - Remove
StoredDraftandStoredPostDraft(replaced by server types)
Phase 2: Implement Server API Hooks
2.1 Replace hooks in src/state/drafts/hooks.ts
useDrafts() → Server getDrafts
export function useDrafts() {
const agent = useAgent()
return useQuery({
queryKey: ['drafts'],
queryFn: async () => {
const res = await agent.app.bsky.draft.getDrafts({})
return res.data.drafts.map(draftViewToSummary)
},
staleTime: STALE.SECONDS.THIRTY,
})
}
useSaveDraft() → Server createDraft / updateDraft
export function useSaveDraft() {
const agent = useAgent()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ composerState, replyTo, existingDraftId }) => {
const draft = composerStateToDraft(composerState, replyTo)
if (existingDraftId) {
await agent.app.bsky.draft.updateDraft({
draft: { id: existingDraftId, draft }
})
return existingDraftId
} else {
const res = await agent.app.bsky.draft.createDraft({ draft })
return res.data.id
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['drafts'] })
},
})
}
useDeleteDraft() → Server deleteDraft
export function useDeleteDraft() {
const agent = useAgent()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (draftId: string) => {
await agent.app.bsky.draft.deleteDraft({ id: draftId })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['drafts'] })
},
})
}
useLoadDraft() → Extract from getDrafts response
// Option A: Fetch single draft from cached list
// Option B: If API adds getDraft(id), use that
2.2 Handle DraftLimitReachedError
Add error handling in save mutation:
onError: (error) => {
if (error instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) {
// Show toast: "Draft limit reached. Delete some drafts to save new ones."
}
}
Phase 3: Media Handling Strategy
3.1 LocalRef Pattern
Media files (images, compressed videos) are stored locally on device. The server draft stores a localRef.path that uniquely identifies the local file.
3.2 Implementation approach
For images:
// When saving draft:
// 1. Copy image to drafts media directory (existing pattern)
// 2. Store the local path in DraftEmbedImage.localRef.path
// When loading draft:
// 1. Check if file exists at localRef.path
// 2. If exists: use it
// 3. If missing: mark draft as having unavailable media (different device)
For videos:
// When saving draft:
// 1. Video compresses as normal (existing flow)
// 2. Save compressed video to drafts media directory
// 3. Store path in DraftEmbedVideo.localRef.path
// When loading: same as images - check existence
For GIFs:
// Store as DraftEmbedExternal with Tenor URL + dimensions in query params
// URL format: https://media.tenor.com/.../AAAAC/...gif?hh=HEIGHT&ww=WIDTH
// No local file needed - rehydrate from URL like feed embeds do
3.3 "Different Device" handling
When fetching drafts list:
- For each draft, check if all localRef paths exist locally
- If any missing, mark draft with
hasMissingMedia: true - UI shows a note: "Some media unavailable (saved on another device)"
- When restoring such a draft, skip the missing media
Phase 4: Update Composer Integration
4.1 Modify restore_from_draft action
Update to accept server Draft type instead of StoredDraft:
case 'restore_from_draft': {
const { draft } = action // Now AppBskyDraftDefs.Draft
const posts = draft.posts.map(serverPost => {
// Convert DraftPost → PostDraft
// Handle embedImages, embedVideos, embedExternals, embedRecords
})
return { ...state, thread: { posts, ... }, isDirty: false }
}
4.2 Update mark_saved action
Now receives server-generated draft ID (TID format).
Phase 5: Remove Local Storage
5.1 Delete files
src/state/drafts/storage.ts(native file storage)src/state/drafts/storage.web.ts(IndexedDB storage)
5.2 Migration cleanup
- Remove
loadedMediaMapfrom composer state (media now server-managed) - Remove
pathToLocalIdreverse mapping logic - Remove media cleanup/reuse detection code
5.3 Update exports in src/state/drafts/index.ts
Phase 6: Update UI Components
6.1 DraftItem.tsx
- Update to use server media URLs directly
- Remove local media loading logic (
storage.loadMediaFromLocal) - Images/videos come with blob URLs from server
6.2 DraftsListDialog.tsx
- Handle pagination with
cursorfrom getDrafts - Add "Load more" or infinite scroll
6.3 DraftsButton.tsx
- No changes needed (already uses hooks abstraction)
Phase 7: Error Handling & Edge Cases
7.1 Network errors
- Show toast on save failure
- Retry logic for transient failures
- Consider optimistic updates with rollback
7.2 Draft limit reached
- Surface
DraftLimitReachedErrorto user - Suggest deleting old drafts
7.3 Deleted drafts
- Handle case where draft was deleted on another device
- Graceful handling in restore flow
Phase 8: Testing
8.1 Update unit tests
src/state/drafts/__tests__/serialization.test.ts- Test type converters (composerStateToDraft, draftViewToSummary)
- Mock server API responses
8.2 Manual testing checklist
- Create new draft
- Edit existing draft
- Delete draft
- Draft with images
- Draft with video
- Draft with GIF
- Draft with quote post
- Draft with external link
- Thread draft (multiple posts)
- Reply draft
- Draft limit error handling
- Cross-device sync verification
Files to Modify
| File | Changes |
|---|---|
src/state/drafts/schema.ts |
Simplify types, remove storage-specific fields |
src/state/drafts/hooks.ts |
Replace with server API calls |
src/state/drafts/api.ts |
NEW - Type converters |
src/state/drafts/index.ts |
Update exports |
src/state/drafts/storage.ts |
DELETE |
src/state/drafts/storage.web.ts |
DELETE |
src/view/com/composer/state/composer.ts |
Update restore_from_draft action |
src/view/com/composer/drafts/DraftItem.tsx |
Use server media URLs |
src/view/com/composer/drafts/DraftsListDialog.tsx |
Add pagination |
src/state/queries/drafts.ts |
DELETE (was placeholder) |
Resolved Questions
-
Media handling: Use localRef with local filepath. Keep media on device, server stores the path reference. Check file existence when loading - show "different device" note if missing.
-
Reply drafts: Not supported. Hide drafts button when replying.
-
GIF handling: Store as
DraftEmbedExternalwith Tenor URL including dimensions in query params (?hh=HEIGHT&ww=WIDTH). Rehydrate from URL pattern like feed embeds. -
Offline support: Server-only for now. No offline queuing.
Estimated Scope
- Phase 1-2: Core API integration (~2-3 files, straightforward)
- Phase 3: Media handling (most complex, depends on API behavior)
- Phase 4-6: Composer & UI updates (moderate)
- Phase 7-8: Polish & testing
The media handling (Phase 3) is the biggest unknown and may require experimentation with the API to understand the exact flow.