Add drafts functionality to composer

- Add local storage layer for drafts (filesystem on native, IndexedDB on web)
- Add "Drafts" button to composer top bar showing badge with draft count
- Modify discard prompt to offer "Save Draft" option
- Add `restore_from_draft` action to composer reducer
- Support saving/restoring: text, facets, images, labels, threadgate, quote/link embeds
- Add placeholder hooks for future server API integration
- Add unit tests for draft serialization

Note: Video/GIF restoration marked as TODO for future implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-13 13:31:54 +02:00
parent fd49349b71
commit 4a2ee05660
15 changed files with 2136 additions and 18 deletions
+60 -12
View File
@@ -76,6 +76,7 @@ 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 {emitPostCreated} from '#/state/events'
import {
type ComposerImage,
@@ -98,6 +99,7 @@ import {useComposerControls} from '#/state/shell/composer'
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
import {DraftsButton} from '#/view/com/composer/drafts/DraftsButton'
import {
ExternalEmbedGif,
ExternalEmbedLink,
@@ -189,6 +191,7 @@ export const ComposePost = ({
const setLangPrefs = useLanguagePrefsApi()
const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const {mutateAsync: saveDraft, isPending: _isSavingDraft} = useSaveDraft()
const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls()
const {data: preferences} = usePreferencesQuery()
@@ -320,6 +323,23 @@ export const ComposePost = ({
[composerDispatch],
)
const handleSelectDraft = React.useCallback(
async (draft: StoredDraft) => {
if (!currentDid) return
// Load media from local storage
const loadedMedia = await loadDraftMedia(currentDid, draft)
// Dispatch restore action
composerDispatch({
type: 'restore_from_draft',
draft,
loadedMedia,
})
},
[currentDid, composerDispatch],
)
const [publishOnUpload, setPublishOnUpload] = useState(false)
const onClose = useCallback(() => {
@@ -327,6 +347,19 @@ export const ComposePost = ({
clearThumbnailCache(queryClient)
}, [closeComposer, queryClient])
const handleSaveDraft = React.useCallback(async () => {
try {
await saveDraft({
composerState,
replyTo,
})
onClose()
} catch (e) {
logger.error('Failed to save draft', {error: e})
setError(_(msg`Failed to save draft`))
}
}, [saveDraft, composerState, replyTo, onClose, _])
const insets = useSafeAreaInsets()
const viewStyles = useMemo(
() => ({
@@ -750,7 +783,8 @@ export const ComposePost = ({
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={onPressPublish}>
onPublish={onPressPublish}
onSelectDraft={handleSelectDraft}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
@@ -801,14 +835,25 @@ export const ComposePost = ({
{!IS_WEBFooterSticky && footer}
</View>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
/>
<Prompt.Outer control={discardPromptControl}>
<Prompt.TitleText>{_(msg`Discard draft?`)}</Prompt.TitleText>
<Prompt.DescriptionText>
{_(msg`You can save this draft to continue later.`)}
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Save Draft`)}
onPress={handleSaveDraft}
color="primary"
/>
<Prompt.Action
cta={_(msg`Discard`)}
onPress={onClose}
color="negative"
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
</KeyboardAvoidingView>
</BottomSheetPortalProvider>
)
@@ -1027,6 +1072,7 @@ function ComposerTopBar({
publishingStage,
onCancel,
onPublish,
onSelectDraft,
topBarAnimatedStyle,
children,
}: {
@@ -1038,6 +1084,7 @@ function ComposerTopBar({
isThread: boolean
onCancel: () => void
onPublish: () => void
onSelectDraft: (draft: StoredDraft) => void
topBarAnimatedStyle: StyleProp<ViewStyle>
children?: React.ReactNode
}) {
@@ -1063,6 +1110,7 @@ function ComposerTopBar({
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<DraftsButton onSelectDraft={onSelectDraft} />
<View style={a.flex_1} />
{isPublishing ? (
<>
@@ -1411,7 +1459,7 @@ function ComposerFooter({
if (assets.length) {
if (type === 'image') {
const images: ComposerImage[] = []
const selectedImages: ComposerImage[] = []
await Promise.all(
assets.map(async image => {
@@ -1421,7 +1469,7 @@ function ComposerFooter({
height: image.height,
mime: image.mimeType!,
})
images.push(composerImage)
selectedImages.push(composerImage)
}),
).catch(e => {
logger.error(`createComposerImage failed`, {
@@ -1429,7 +1477,7 @@ function ComposerFooter({
})
})
onImageAdd(images)
onImageAdd(selectedImages)
} else if (type === 'video') {
onSelectVideo(post.id, assets[0])
} else if (type === 'gif') {
+103
View File
@@ -0,0 +1,103 @@
import {Pressable, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {type DraftSummary} from '#/state/drafts'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Camera_Stroke2_Corner0_Rounded as MediaIcon} from '#/components/icons/Camera'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Text} from '#/components/Typography'
export function DraftItem({
draft,
onSelect,
onDelete,
isDeleting,
}: {
draft: DraftSummary
onSelect: (draft: DraftSummary) => void
onDelete: (draftId: string) => void
isDeleting: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const getTimeAgo = useGetTimeAgo()
const previewText = draft.previewText || _(msg`(No text)`)
const timeAgo = getTimeAgo(new Date(draft.updatedAt), new Date())
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Open draft: ${previewText}`)}
accessibilityHint={_(msg`Opens this draft in the composer`)}
onPress={() => onSelect(draft)}
style={({pressed, hovered}) => [
a.flex_row,
a.align_center,
a.gap_md,
a.p_md,
a.rounded_md,
t.atoms.bg_contrast_25,
(pressed || hovered) && t.atoms.bg_contrast_50,
]}>
<View style={[a.flex_1, a.gap_xs]}>
{/* Reply indicator */}
{draft.isReply && draft.replyToHandle && (
<Text
style={[a.text_xs, t.atoms.text_contrast_medium]}
numberOfLines={1}>
<Trans>Replying to @{draft.replyToHandle}</Trans>
</Text>
)}
{/* Preview text */}
<Text style={[a.text_md]} numberOfLines={2}>
{previewText}
</Text>
{/* Metadata row */}
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
{/* Time ago */}
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{timeAgo}
</Text>
{/* Media indicator */}
{draft.hasMedia && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<MediaIcon size="xs" style={[t.atoms.text_contrast_medium]} />
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{draft.mediaCount}
</Text>
</View>
)}
{/* Thread indicator */}
{draft.postCount > 1 && (
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>{draft.postCount} posts</Trans>
</Text>
)}
</View>
</View>
{/* Delete button */}
<Button
label={_(msg`Delete draft`)}
variant="ghost"
color="negative"
shape="round"
size="small"
disabled={isDeleting}
onPress={e => {
e.stopPropagation()
onDelete(draft.id)
}}>
<ButtonIcon icon={TrashIcon} />
</Button>
</Pressable>
)
}
@@ -0,0 +1,58 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type StoredDraft, useDrafts} from '#/state/drafts'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {PageText_Stroke2_Corner0_Rounded as DraftIcon} from '#/components/icons/PageText'
import {Text} from '#/components/Typography'
import {DraftsListDialog} from './DraftsListDialog'
export function DraftsButton({
onSelectDraft,
}: {
onSelectDraft: (draft: StoredDraft) => void
}) {
const {_} = useLingui()
const t = useTheme()
const control = Dialog.useDialogControl()
const {data: drafts, isLoading} = useDrafts()
const hasDrafts = drafts && drafts.length > 0
if (isLoading || !hasDrafts) {
return null
}
return (
<>
<Button
label={_(msg`See drafts`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_xs, a.px_sm, a.ml_xs]}
onPress={() => control.open()}>
<DraftIcon size="sm" style={[t.atoms.text_contrast_medium]} />
<ButtonText style={[a.text_sm]}>
<Trans>Drafts</Trans>
</ButtonText>
<View
style={[
a.rounded_full,
a.px_xs,
a.ml_2xs,
{backgroundColor: t.palette.primary_500},
]}>
<Text style={[a.text_xs, a.font_bold, {color: t.palette.white}]}>
{drafts.length}
</Text>
</View>
</Button>
<DraftsListDialog control={control} onSelectDraft={onSelectDraft} />
</>
)
}
@@ -0,0 +1,89 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type DraftSummary,
type StoredDraft,
useDeleteDraft,
useDrafts,
useLoadDraft,
} from '#/state/drafts'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {DraftItem} from './DraftItem'
export function DraftsListDialog({
control,
onSelectDraft,
}: {
control: Dialog.DialogControlProps
onSelectDraft: (draft: StoredDraft) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {data: drafts, isLoading} = useDrafts()
const loadDraft = useLoadDraft()
const {mutate: deleteDraft, isPending: isDeleting} = useDeleteDraft()
const handleSelectDraft = useCallback(
async (summary: DraftSummary) => {
const draft = await loadDraft(summary.id)
if (draft) {
control.close(() => {
onSelectDraft(draft)
})
}
},
[loadDraft, control, onSelectDraft],
)
const handleDeleteDraft = useCallback(
(draftId: string) => {
deleteDraft(draftId)
},
[deleteDraft],
)
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Your Drafts`)}>
<View style={[a.gap_md]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Your Drafts</Trans>
</Text>
{isLoading ? (
<View style={[a.py_xl, a.align_center]}>
<Loader size="lg" />
</View>
) : drafts && drafts.length > 0 ? (
<View style={[a.gap_sm]}>
{drafts.map(draft => (
<DraftItem
key={draft.id}
draft={draft}
onSelect={handleSelectDraft}
onDelete={handleDeleteDraft}
isDeleting={isDeleting}
/>
))}
</View>
) : (
<View style={[a.py_xl, a.align_center]}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>No drafts saved</Trans>
</Text>
</View>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+77
View File
@@ -15,6 +15,7 @@ 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'
@@ -122,6 +123,12 @@ export type ComposerAction =
type: 'focus_post'
postId: string
}
| {
type: 'restore_from_draft'
draft: StoredDraft
/** Map of localId -> loaded media path/URL */
loadedMedia: Map<string, string>
}
export const MAX_IMAGES = 4
@@ -229,6 +236,76 @@ export function composerReducer(
activePostIndex: nextActivePostIndex,
}
}
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,
})
// 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}
}
}
// 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 {
activePostIndex: 0,
mutableNeedsFocusActive: true,
thread: {
posts,
postgate: draft.postgate || state.thread.postgate,
threadgate: draft.threadgate || state.thread.threadgate,
},
}
}
}
}