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: [], labels: [],
}, },
], ],
threadgate: ['nobody'], threadgate: [{type: 'nobody'}],
syncStatus: 'local', syncStatus: 'local',
} }
@@ -428,7 +428,7 @@ describe('Draft serialization', () => {
loadedMedia, loadedMedia,
}) })
expect(newState.thread.threadgate).toEqual(['nobody']) expect(newState.thread.threadgate).toEqual([{type: 'nobody'}])
}) })
it('restores reply information', () => { it('restores reply information', () => {
+38 -2
View File
@@ -360,6 +360,32 @@ export const ComposePost = ({
} }
}, [saveDraft, composerState, replyTo, onClose, _]) }, [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 insets = useSafeAreaInsets()
const viewStyles = useMemo( const viewStyles = useMemo(
() => ({ () => ({
@@ -784,7 +810,9 @@ export const ComposePost = ({
topBarAnimatedStyle={topBarAnimatedStyle} topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel} onCancel={onPressCancel}
onPublish={onPressPublish} onPublish={onPressPublish}
onSelectDraft={handleSelectDraft}> onSelectDraft={handleSelectDraft}
onSaveDraft={saveCurrentDraft}
isEmpty={isComposerEmpty}>
{missingAltError && <AltTextReminder error={missingAltError} />} {missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner <ErrorBanner
error={error} error={error}
@@ -1073,6 +1101,8 @@ function ComposerTopBar({
onCancel, onCancel,
onPublish, onPublish,
onSelectDraft, onSelectDraft,
onSaveDraft,
isEmpty,
topBarAnimatedStyle, topBarAnimatedStyle,
children, children,
}: { }: {
@@ -1085,6 +1115,8 @@ function ComposerTopBar({
onCancel: () => void onCancel: () => void
onPublish: () => void onPublish: () => void
onSelectDraft: (draft: StoredDraft) => void onSelectDraft: (draft: StoredDraft) => void
onSaveDraft: () => Promise<void>
isEmpty: boolean
topBarAnimatedStyle: StyleProp<ViewStyle> topBarAnimatedStyle: StyleProp<ViewStyle>
children?: React.ReactNode children?: React.ReactNode
}) { }) {
@@ -1110,8 +1142,12 @@ function ComposerTopBar({
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
<DraftsButton onSelectDraft={onSelectDraft} />
<View style={a.flex_1} /> <View style={a.flex_1} />
<DraftsButton
onSelectDraft={onSelectDraft}
onSaveDraft={onSaveDraft}
isEmpty={isEmpty}
/>
{isPublishing ? ( {isPublishing ? (
<> <>
<Text style={pal.textLight}>{publishingStage}</Text> <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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {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 * as Dialog from '#/components/Dialog'
import {PageText_Stroke2_Corner0_Rounded as DraftIcon} from '#/components/icons/PageText' import {PageText_Stroke2_Corner0_Rounded as DraftIcon} from '#/components/icons/PageText'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {DraftsListDialog} from './DraftsListDialog' import {DraftsListDialog} from './DraftsListDialog'
export function DraftsButton({ export function DraftsButton({
onSelectDraft, onSelectDraft,
onSaveDraft,
isEmpty,
}: { }: {
onSelectDraft: (draft: StoredDraft) => void onSelectDraft: (draft: StoredDraft) => void
onSaveDraft: () => Promise<void>
isEmpty: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const control = Dialog.useDialogControl() const draftsDialogControl = Dialog.useDialogControl()
const {data: drafts, isLoading} = useDrafts() 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) { const handlePress = () => {
return null 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 ( return (
<> <>
<Button <Button
label={_(msg`See drafts`)} label={_(msg`Drafts`)}
variant="ghost" variant="ghost"
color="primary" color="secondary"
shape="default" shape="round"
size="small" size="small"
style={[a.rounded_full, a.py_xs, a.px_sm, a.ml_xs]} style={[a.mx_xs]}
onPress={() => control.open()}> disabled={isSaving}
<DraftIcon size="sm" style={[t.atoms.text_contrast_medium]} /> onPress={handlePress}>
<ButtonText style={[a.text_sm]}> <ButtonIcon icon={DraftIcon} />
<Trans>Drafts</Trans> {draftCount > 0 && (
</ButtonText> <View
<View style={[
style={[ a.absolute,
a.rounded_full, a.rounded_full,
a.px_xs, {
a.ml_2xs, top: -2,
{backgroundColor: t.palette.primary_500}, right: -2,
]}> minWidth: 16,
<Text style={[a.text_xs, a.font_bold, {color: t.palette.white}]}> height: 16,
{drafts.length} backgroundColor: t.palette.primary_500,
</Text> alignItems: 'center',
</View> justifyContent: 'center',
paddingHorizontal: 4,
},
]}>
<Text style={[a.text_2xs, a.font_bold, {color: t.palette.white}]}>
{draftCount}
</Text>
</View>
)}
</Button> </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>
</> </>
) )
} }