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