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