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:
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user