Fix drafts button: always visible, adjacent to post button

- Make drafts button always visible (not just when drafts exist)
- Move button to be adjacent to the publish button
- If composer is empty: opens drafts list directly
- If composer has content: shows prompt to save/discard before viewing drafts
- Add badge showing draft count when drafts exist

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-13 13:51:38 +02:00
parent 4a2ee05660
commit 4eb064b6a4
3 changed files with 127 additions and 32 deletions
@@ -416,7 +416,7 @@ describe('Draft serialization', () => {
labels: [],
},
],
threadgate: ['nobody'],
threadgate: [{type: 'nobody'}],
syncStatus: 'local',
}
@@ -428,7 +428,7 @@ describe('Draft serialization', () => {
loadedMedia,
})
expect(newState.thread.threadgate).toEqual(['nobody'])
expect(newState.thread.threadgate).toEqual([{type: 'nobody'}])
})
it('restores reply information', () => {
+38 -2
View File
@@ -360,6 +360,32 @@ export const ComposePost = ({
}
}, [saveDraft, composerState, replyTo, onClose, _])
// Save without closing - for use by DraftsButton
const saveCurrentDraft = React.useCallback(async () => {
await saveDraft({
composerState,
replyTo,
})
}, [saveDraft, composerState, replyTo])
// Check if composer is empty (no content to save)
const isComposerEmpty = React.useMemo(() => {
// Has multiple posts means it's not empty
if (thread.posts.length > 1) return false
const firstPost = thread.posts[0]
// Has text
if (firstPost.richtext.text.trim().length > 0) return false
// Has media
if (firstPost.embed.media) return false
// Has quote
if (firstPost.embed.quote) return false
// Has link
if (firstPost.embed.link) return false
return true
}, [thread.posts])
const insets = useSafeAreaInsets()
const viewStyles = useMemo(
() => ({
@@ -784,7 +810,9 @@ export const ComposePost = ({
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={onPressPublish}
onSelectDraft={handleSelectDraft}>
onSelectDraft={handleSelectDraft}
onSaveDraft={saveCurrentDraft}
isEmpty={isComposerEmpty}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
@@ -1073,6 +1101,8 @@ function ComposerTopBar({
onCancel,
onPublish,
onSelectDraft,
onSaveDraft,
isEmpty,
topBarAnimatedStyle,
children,
}: {
@@ -1085,6 +1115,8 @@ function ComposerTopBar({
onCancel: () => void
onPublish: () => void
onSelectDraft: (draft: StoredDraft) => void
onSaveDraft: () => Promise<void>
isEmpty: boolean
topBarAnimatedStyle: StyleProp<ViewStyle>
children?: React.ReactNode
}) {
@@ -1110,8 +1142,12 @@ function ComposerTopBar({
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<DraftsButton onSelectDraft={onSelectDraft} />
<View style={a.flex_1} />
<DraftsButton
onSelectDraft={onSelectDraft}
onSaveDraft={onSaveDraft}
isEmpty={isEmpty}
/>
{isPublishing ? (
<>
<Text style={pal.textLight}>{publishingStage}</Text>
+87 -28
View File
@@ -2,57 +2,116 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type StoredDraft, useDrafts} from '#/state/drafts'
import {type StoredDraft, useDrafts, useSaveDraft} from '#/state/drafts'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {PageText_Stroke2_Corner0_Rounded as DraftIcon} from '#/components/icons/PageText'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {DraftsListDialog} from './DraftsListDialog'
export function DraftsButton({
onSelectDraft,
onSaveDraft,
isEmpty,
}: {
onSelectDraft: (draft: StoredDraft) => void
onSaveDraft: () => Promise<void>
isEmpty: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const control = Dialog.useDialogControl()
const {data: drafts, isLoading} = useDrafts()
const draftsDialogControl = Dialog.useDialogControl()
const savePromptControl = Prompt.usePromptControl()
const {data: drafts} = useDrafts()
const {isPending: isSaving} = useSaveDraft()
const hasDrafts = drafts && drafts.length > 0
const draftCount = drafts?.length ?? 0
if (isLoading || !hasDrafts) {
return null
const handlePress = () => {
if (isEmpty) {
// Composer is empty, go directly to drafts list
draftsDialogControl.open()
} else {
// Composer has content, ask what to do
savePromptControl.open()
}
}
const handleSaveAndOpen = async () => {
await onSaveDraft()
draftsDialogControl.open()
}
const handleDiscardAndOpen = () => {
draftsDialogControl.open()
}
return (
<>
<Button
label={_(msg`See drafts`)}
label={_(msg`Drafts`)}
variant="ghost"
color="primary"
shape="default"
color="secondary"
shape="round"
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>
style={[a.mx_xs]}
disabled={isSaving}
onPress={handlePress}>
<ButtonIcon icon={DraftIcon} />
{draftCount > 0 && (
<View
style={[
a.absolute,
a.rounded_full,
{
top: -2,
right: -2,
minWidth: 16,
height: 16,
backgroundColor: t.palette.primary_500,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 4,
},
]}>
<Text style={[a.text_2xs, a.font_bold, {color: t.palette.white}]}>
{draftCount}
</Text>
</View>
)}
</Button>
<DraftsListDialog control={control} onSelectDraft={onSelectDraft} />
<DraftsListDialog
control={draftsDialogControl}
onSelectDraft={onSelectDraft}
/>
<Prompt.Outer control={savePromptControl}>
<Prompt.TitleText>
<Trans>Save current draft?</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
You have unsaved changes. Would you like to save them before viewing
your drafts?
</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Save & View Drafts`)}
onPress={handleSaveAndOpen}
color="primary"
/>
<Prompt.Action
cta={_(msg`Discard & View Drafts`)}
onPress={handleDiscardAndOpen}
color="negative"
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}