Migrate drafts from local storage to server API

Replace local-only draft storage with the new `app.bsky.draft.*` server API:
- getDrafts, createDraft, updateDraft, deleteDraft endpoints

Key changes:
- Add api.ts with type converters (ComposerState <-> server Draft)
- Update hooks.ts to use server API instead of local storage
- Simplify storage.ts/storage.web.ts for local media caching only
- Media stored locally via localRef pattern (filepath in server draft)
- GIFs stored as external embeds with Tenor URL + dimensions
- Hide drafts button when replying (reply drafts not supported)
- Show "different device" note when media is missing locally

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-15 18:53:12 +02:00
parent 2dbee371f3
commit 666e1318b2
12 changed files with 853 additions and 1648 deletions
+38 -25
View File
@@ -76,7 +76,13 @@ import {cleanError} from '#/lib/strings/errors'
import {colors} from '#/lib/styles'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
import {loadDraftMedia, type StoredDraft, useSaveDraft} from '#/state/drafts'
import {
type DraftSummary,
draftToComposerPosts,
threadgateToUISettings,
useLoadDraft,
useSaveDraft,
} from '#/state/drafts'
import {emitPostCreated} from '#/state/events'
import {
type ComposerImage,
@@ -192,6 +198,7 @@ export const ComposePost = ({
const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const {mutateAsync: saveDraft, isPending: _isSavingDraft} = useSaveDraft()
const loadDraft = useLoadDraft()
const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls()
const {data: preferences} = usePreferencesQuery()
@@ -324,20 +331,27 @@ export const ComposePost = ({
)
const handleSelectDraft = React.useCallback(
async (draft: StoredDraft) => {
if (!currentDid) return
async (draftSummary: DraftSummary) => {
// Load full draft from server with media
const result = await loadDraft(draftSummary.id)
if (!result) return
// Load media from local storage
const loadedMedia = await loadDraftMedia(currentDid, draft)
const {draft, loadedMedia} = result
// Convert server draft to composer posts
const posts = draftToComposerPosts(draft, loadedMedia)
const threadgate = threadgateToUISettings(draft.threadgateAllow)
// Dispatch restore action (this also sets draftId in state)
composerDispatch({
type: 'restore_from_draft',
draft,
draftId: draftSummary.id,
posts,
threadgate,
loadedMedia,
})
},
[currentDid, composerDispatch],
[loadDraft, composerDispatch],
)
const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -349,30 +363,26 @@ export const ComposePost = ({
const handleSaveDraft = React.useCallback(async () => {
try {
const savedDraft = await saveDraft({
const draftId = await saveDraft({
composerState,
replyTo,
existingDraftId: composerState.draftId,
loadedMediaMap: composerState.loadedMediaMap,
})
composerDispatch({type: 'mark_saved', draftId: savedDraft.id})
composerDispatch({type: 'mark_saved', draftId})
onClose()
} catch (e) {
logger.error('Failed to save draft', {error: e})
setError(_(msg`Failed to save draft`))
}
}, [saveDraft, composerState, replyTo, composerDispatch, onClose, _])
}, [saveDraft, composerState, composerDispatch, onClose, _])
// Save without closing - for use by DraftsButton
const saveCurrentDraft = React.useCallback(async () => {
const savedDraft = await saveDraft({
const draftId = await saveDraft({
composerState,
replyTo,
existingDraftId: composerState.draftId,
loadedMediaMap: composerState.loadedMediaMap,
})
composerDispatch({type: 'mark_saved', draftId: savedDraft.id})
}, [saveDraft, composerState, replyTo, composerDispatch])
composerDispatch({type: 'mark_saved', draftId})
}, [saveDraft, composerState, composerDispatch])
// Check if composer is empty (no content to save)
const isComposerEmpty = React.useMemo(() => {
@@ -1143,7 +1153,7 @@ function ComposerTopBar({
isThread: boolean
onCancel: () => void
onPublish: () => void
onSelectDraft: (draft: StoredDraft) => void
onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<void>
onDiscard: () => void
isEmpty: boolean
@@ -1174,13 +1184,16 @@ function ComposerTopBar({
</ButtonText>
</Button>
<View style={a.flex_1} />
<DraftsButton
onSelectDraft={onSelectDraft}
onSaveDraft={onSaveDraft}
onDiscard={onDiscard}
isEmpty={isEmpty}
isDirty={isDirty}
/>
{/* Drafts not supported for replies */}
{!isReply && (
<DraftsButton
onSelectDraft={onSelectDraft}
onSaveDraft={onSaveDraft}
onDiscard={onDiscard}
isEmpty={isEmpty}
isDirty={isDirty}
/>
)}
{isPublishing ? (
<>
<Text style={pal.textLight}>{publishingStage}</Text>
+51 -46
View File
@@ -9,7 +9,7 @@ import {isNative} from '#/platform/detection'
import {
type DraftPostDisplay,
type DraftSummary,
type LocalMediaRef,
type LocalMediaDisplay,
} from '#/state/drafts'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {useSession} from '#/state/session'
@@ -62,13 +62,20 @@ export function DraftItem({
(pressed || hovered) && t.atoms.bg_contrast_25,
]}>
<View style={[a.p_md, a.gap_sm]}>
{/* Reply indicator */}
{draft.isReply && draft.replyToHandle && (
<Text
style={[a.text_xs, t.atoms.text_contrast_medium, a.pb_2xs]}
numberOfLines={1}>
<Trans>Replying to @{draft.replyToHandle}</Trans>
</Text>
{/* Missing media warning */}
{draft.hasMissingMedia && (
<View
style={[
a.rounded_sm,
a.px_sm,
a.py_xs,
a.mb_xs,
t.atoms.bg_contrast_100,
]}>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>Some media unavailable (saved on another device)</Trans>
</Text>
</View>
)}
{/* Posts */}
@@ -207,61 +214,50 @@ function DraftPostRow({
type LoadedImage = {
url: string
meta: LocalMediaRef
meta: LocalMediaDisplay
}
function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
const t = useTheme()
const {currentAccount} = useSession()
const [loadedImages, setLoadedImages] = useState<LoadedImage[]>([])
const [gifUrl, setGifUrl] = useState<string | null>(null)
useEffect(() => {
async function loadMedia() {
if (!currentAccount?.did) return
// Load images
// Load images that exist locally
if (post.images && post.images.length > 0) {
const loaded: LoadedImage[] = []
for (const image of post.images) {
try {
const url = await storage.loadMediaFromLocal(
currentAccount.did,
image.localId,
)
loaded.push({url, meta: image})
} catch (e) {
// Image might not exist anymore
console.warn('Failed to load draft image', e)
if (image.exists) {
try {
const url = await storage.loadMediaFromLocal(image.localPath)
loaded.push({url, meta: image})
} catch (e) {
console.warn('Failed to load draft image', e)
}
}
}
setLoadedImages(loaded)
}
// GIFs have a URL directly
if (post.gif) {
setGifUrl(post.gif.url)
}
}
loadMedia()
}, [currentAccount?.did, post.images, post.gif])
}, [post.images])
// Convert loaded images to ViewImage format for the embed components
const viewImages = useMemo<AppBskyEmbedImages.ViewImage[]>(() => {
return loadedImages.map(({url, meta}) => ({
return loadedImages.map(({url}) => ({
thumb: url,
fullsize: url,
alt: meta.altText || '',
aspectRatio:
meta.width && meta.height
? {width: meta.width, height: meta.height}
: undefined,
alt: '',
aspectRatio: undefined, // No dimensions stored in new schema
}))
}, [loadedImages])
// Count missing images
const missingImageCount = post.images?.filter(img => !img.exists).length ?? 0
// Nothing to show
if (viewImages.length === 0 && !gifUrl && !post.video) {
if (viewImages.length === 0 && !post.gif && !post.video) {
return null
}
@@ -273,8 +269,18 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
)}
{viewImages.length > 1 && <ImageLayoutGrid images={viewImages} />}
{/* Missing images note */}
{missingImageCount > 0 && (
<Text style={[a.text_xs, t.atoms.text_contrast_medium, a.mt_xs]}>
<Trans>
{missingImageCount} image{missingImageCount > 1 ? 's' : ''} not
available
</Trans>
</Text>
)}
{/* GIF preview */}
{gifUrl && (
{post.gif && (
<View
style={[
a.rounded_md,
@@ -282,13 +288,13 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
t.atoms.bg_contrast_25,
{
aspectRatio:
post.gif?.width && post.gif?.height
post.gif.width && post.gif.height
? post.gif.width / post.gif.height
: 16 / 9,
},
]}>
<Image
source={{uri: gifUrl}}
source={{uri: post.gif.url}}
style={[a.flex_1]}
contentFit="cover"
accessibilityIgnoresInvertColors
@@ -305,15 +311,14 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
a.align_center,
a.justify_center,
t.atoms.bg_contrast_50,
{
aspectRatio:
post.video.width && post.video.height
? post.video.width / post.video.height
: 16 / 9,
},
{aspectRatio: 16 / 9},
]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Video attached</Trans>
{post.video.exists ? (
<Trans>Video attached</Trans>
) : (
<Trans>Video not available</Trans>
)}
</Text>
</View>
)}
@@ -1,7 +1,7 @@
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type StoredDraft, useSaveDraft} from '#/state/drafts'
import {type DraftSummary, useSaveDraft} from '#/state/drafts'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -15,7 +15,7 @@ export function DraftsButton({
isEmpty,
isDirty,
}: {
onSelectDraft: (draft: StoredDraft) => void
onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<void>
onDiscard: () => void
isEmpty: boolean
@@ -4,13 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {
type DraftSummary,
type StoredDraft,
useDeleteDraft,
useDrafts,
useLoadDraft,
} from '#/state/drafts'
import {type DraftSummary, useDeleteDraft, useDrafts} from '#/state/drafts'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -24,24 +18,20 @@ export function DraftsListDialog({
onSelectDraft,
}: {
control: Dialog.DialogControlProps
onSelectDraft: (draft: StoredDraft) => void
onSelectDraft: (draft: DraftSummary) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {data: drafts, isLoading} = useDrafts()
const loadDraft = useLoadDraft()
const {mutate: deleteDraft} = useDeleteDraft()
const handleSelectDraft = useCallback(
async (summary: DraftSummary) => {
const draft = await loadDraft(summary.id)
if (draft) {
control.close(() => {
onSelectDraft(draft)
})
}
(summary: DraftSummary) => {
control.close(() => {
onSelectDraft(summary)
})
},
[loadDraft, control, onSelectDraft],
[control, onSelectDraft],
)
const handleDeleteDraft = useCallback(
+25 -62
View File
@@ -15,7 +15,6 @@ import {
postUriToRelativePath,
toBskyAppUrl,
} from '#/lib/strings/url-helpers'
import {type StoredDraft} from '#/state/drafts/schema'
import {type ComposerImage, createInitialImages} from '#/state/gallery'
import {createPostgateRecord} from '#/state/queries/postgate/util'
import {type Gif} from '#/state/queries/tenor'
@@ -131,8 +130,10 @@ export type ComposerAction =
}
| {
type: 'restore_from_draft'
draft: StoredDraft
/** Map of localId -> loaded media path/URL */
draftId: string
posts: PostDraft[]
threadgate: Array<{type: string; list?: string}>
/** Map of localRefPath -> loaded media path/URL */
loadedMedia: Map<string, string>
}
| {
@@ -255,75 +256,37 @@ export function composerReducer(
}
}
case 'restore_from_draft': {
const {draft, loadedMedia} = action
const posts: PostDraft[] = draft.posts.map(storedPost => {
// Reconstruct RichText
const richtext = new RichText({
text: storedPost.richtext.text,
facets: storedPost.richtext.facets,
})
const {draftId, posts, threadgate, loadedMedia} = action
// Reconstruct embed
const embed: EmbedDraft = {
quote: storedPost.quoteUri
? {type: 'link', uri: storedPost.quoteUri}
: undefined,
link: storedPost.linkUri
? {type: 'link', uri: storedPost.linkUri}
: undefined,
media: undefined,
}
// Restore images
if (storedPost.images && storedPost.images.length > 0) {
const images: ComposerImage[] = storedPost.images
.map(img => {
const path = loadedMedia.get(img.localId)
if (!path) return null
return {
alt: img.altText,
source: {
id: nanoid(),
path,
width: img.width,
height: img.height,
mime: img.mimeType,
},
}
})
.filter((img): img is ComposerImage => img !== null)
if (images.length > 0) {
embed.media = {type: 'images', images}
// Convert threadgate to UI settings format
const threadgateSettings: ThreadgateAllowUISetting[] = threadgate.map(
rule => {
if (rule.type === 'mention') {
return {type: 'mention'} as ThreadgateAllowUISetting
} else if (rule.type === 'following') {
return {type: 'following'} as ThreadgateAllowUISetting
} else if (rule.type === 'followers') {
return {type: 'followers'} as ThreadgateAllowUISetting
} else if (rule.type === 'list' && rule.list) {
return {type: 'list', list: rule.list} as ThreadgateAllowUISetting
}
}
// Note: Videos require re-upload, so we store the path but mark as needing processing
// For now, we skip restoring videos as they'd need re-compression and upload
// TODO: Implement video restoration with re-upload flow
// Note: GIFs could be restored by re-fetching from Tenor using the stored ID
// TODO: Implement GIF restoration
return {
id: storedPost.id,
richtext,
shortenedGraphemeLength: getShortenedLength(richtext),
labels: storedPost.labels as SelfLabel[],
embed,
}
})
return {type: 'mention'} as ThreadgateAllowUISetting // fallback
},
)
return {
activePostIndex: 0,
mutableNeedsFocusActive: true,
draftId: draft.id,
draftId,
isDirty: false,
loadedMediaMap: loadedMedia,
thread: {
posts,
postgate: draft.postgate || state.thread.postgate,
threadgate: draft.threadgate || state.thread.threadgate,
postgate: state.thread.postgate,
threadgate:
threadgateSettings.length > 0
? threadgateSettings
: state.thread.threadgate,
},
}
}