[APP-1833] Handle errors on saving a draft over char limit (#9850)

* show composer error if draft over char limit

* adjust cancel discarding if over the limit

* use richtext for validation

* don't allow saving a draft that can't actually be saved

* account for 1k chars

* update discard sheet

* show composer error if draft over char limit

* adjust cancel discarding if over the limit

* use richtext for validation

* don't allow saving a draft that can't actually be saved

* account for 1k chars

* update discard sheet

* pr comment fixes

* pluralization
This commit is contained in:
Spence Pope
2026-02-12 10:24:38 -05:00
committed by GitHub
parent c46219bc4d
commit 2f156bb906
4 changed files with 148 additions and 49 deletions
+2
View File
@@ -62,6 +62,8 @@ export const MAX_DESCRIPTION = 256
export const MAX_GRAPHEME_LENGTH = 300
export const MAX_DRAFT_GRAPHEME_LENGTH = 1000
export const MAX_DM_GRAPHEME_LENGTH = 1000
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
+109 -31
View File
@@ -45,6 +45,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as FileSystem from 'expo-file-system'
import {type ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyDraftCreateDraft,
AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadV2,
AtUri,
@@ -62,6 +63,7 @@ import {useAppState} from '#/lib/appState'
import {retry} from '#/lib/async/retry'
import {until} from '#/lib/async/until'
import {
MAX_DRAFT_GRAPHEME_LENGTH,
MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES,
type SupportedMimeTypes,
@@ -275,6 +277,13 @@ export const ComposePost = ({
)
const thread = composerState.thread
// Clear error when composer content changes, but only if all posts are
// back within the character limit.
const allPostsWithinLimit = thread.posts.every(
post => post.richtext.graphemeLength <= MAX_DRAFT_GRAPHEME_LENGTH,
)
const activePost = thread.posts[composerState.activePostIndex]
const nextPost: PostDraft | undefined =
thread.posts[composerState.activePostIndex + 1]
@@ -543,7 +552,36 @@ export const ComposePost = ({
revokeAllMediaUrls()
}, [closeComposer, queryClient])
const getDraftSaveError = React.useCallback(
(e: unknown): string => {
if (e instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) {
return _(msg`You've reached the maximum number of drafts`)
}
return _(msg`Failed to save draft`)
},
[_],
)
const validateDraftTextOrError = React.useCallback((): boolean => {
const tooLong = composerState.thread.posts.some(
post => post.richtext.graphemeLength > MAX_DRAFT_GRAPHEME_LENGTH,
)
if (tooLong) {
setError(
_(
msg`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`,
),
)
return false
}
return true
}, [composerState.thread.posts, _])
const handleSaveDraft = React.useCallback(async () => {
setError('')
if (!validateDraftTextOrError()) {
return
}
const isNewDraft = !composerState.draftId
try {
const result = await saveDraft({
@@ -569,18 +607,44 @@ export const ComposePost = ({
onClose()
} catch (e) {
logger.error('Failed to save draft', {error: e})
setError(_(msg`Failed to save draft`))
setError(getDraftSaveError(e))
}
}, [saveDraft, composerState, composerDispatch, onClose, _, ax])
}, [
saveDraft,
composerState,
composerDispatch,
onClose,
ax,
validateDraftTextOrError,
getDraftSaveError,
])
// Save without closing - for use by DraftsButton
const saveCurrentDraft = React.useCallback(async () => {
const result = await saveDraft({
composerState,
existingDraftId: composerState.draftId,
})
composerDispatch({type: 'mark_saved', draftId: result.draftId})
}, [saveDraft, composerState, composerDispatch])
const saveCurrentDraft = React.useCallback(async (): Promise<{
success: boolean
}> => {
setError('')
if (!validateDraftTextOrError()) {
return {success: false}
}
try {
const result = await saveDraft({
composerState,
existingDraftId: composerState.draftId,
})
composerDispatch({type: 'mark_saved', draftId: result.draftId})
return {success: true}
} catch (e) {
setError(getDraftSaveError(e))
return {success: false}
}
}, [
saveDraft,
composerState,
composerDispatch,
validateDraftTextOrError,
getDraftSaveError,
])
// Handle discard action - fires metric and closes composer
const handleDiscard = React.useCallback(() => {
@@ -1090,6 +1154,7 @@ export const ComposePost = ({
isEmpty={isComposerEmpty}
isDirty={composerState.isDirty}
isEditingDraft={!!composerState.draftId}
canSaveDraft={allPostsWithinLimit}
textLength={thread.posts[0].richtext.text.length}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
@@ -1154,41 +1219,51 @@ export const ComposePost = ({
<Prompt.Outer control={discardPromptControl}>
<Prompt.Content>
<Prompt.TitleText>
{composerState.draftId ? (
<Trans>Save changes?</Trans>
{allPostsWithinLimit ? (
composerState.draftId ? (
<Trans>Save changes?</Trans>
) : (
<Trans>Save draft?</Trans>
)
) : (
<Trans>Save draft?</Trans>
<Trans>Discard post?</Trans>
)}
</Prompt.TitleText>
<Prompt.DescriptionText>
{composerState.draftId ? (
<Trans>
You have unsaved changes to this draft, would you like to
save them?
</Trans>
{allPostsWithinLimit ? (
composerState.draftId ? (
<Trans>
You have unsaved changes to this draft, would you like to
save them?
</Trans>
) : (
<Trans>
Would you like to save this as a draft to edit later?
</Trans>
)
) : (
<Trans>
Would you like to save this as a draft to edit later?
</Trans>
<Trans>You can only save drafts up to 1000 characters.</Trans>
)}
</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
cta={
composerState.draftId
? _(msg`Save changes`)
: _(msg`Save draft`)
}
onPress={handleSaveDraft}
color="primary"
/>
{allPostsWithinLimit && (
<Prompt.Action
cta={
composerState.draftId
? _(msg`Save changes`)
: _(msg`Save draft`)
}
onPress={handleSaveDraft}
color="primary"
/>
)}
<Prompt.Action
cta={_(msg`Discard`)}
onPress={handleDiscard}
color="negative_subtle"
/>
<Prompt.Cancel />
<Prompt.Cancel cta={_(msg`Keep editing`)} />
</Prompt.Actions>
</Prompt.Outer>
)}
@@ -1416,6 +1491,7 @@ function ComposerTopBar({
isEmpty,
isDirty,
isEditingDraft,
canSaveDraft,
textLength,
topBarAnimatedStyle,
children,
@@ -1429,11 +1505,12 @@ function ComposerTopBar({
onCancel: () => void
onPublish: () => void
onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<void>
onSaveDraft: () => Promise<{success: boolean}>
onDiscard: () => void
isEmpty: boolean
isDirty: boolean
isEditingDraft: boolean
canSaveDraft: boolean
textLength: number
topBarAnimatedStyle: StyleProp<ViewStyle>
children?: React.ReactNode
@@ -1481,6 +1558,7 @@ function ComposerTopBar({
isEmpty={isEmpty}
isDirty={isDirty}
isEditingDraft={isEditingDraft}
canSaveDraft={canSaveDraft}
textLength={textLength}
/>
)}
@@ -98,6 +98,7 @@ export function DraftItem({
{!!post.text.trim().length && (
<RichText
style={[a.text_md, a.leading_snug, a.pointer_events_none]}
numberOfLines={8}
value={post.text}
enableTags
disableMentionFacetValidation
+36 -18
View File
@@ -17,14 +17,16 @@ export function DraftsButton({
isEmpty,
isDirty,
isEditingDraft,
canSaveDraft,
textLength,
}: {
onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<void>
onSaveDraft: () => Promise<{success: boolean}>
onDiscard: () => void
isEmpty: boolean
isDirty: boolean
isEditingDraft: boolean
canSaveDraft: boolean
textLength: number
}) {
const {_} = useLingui()
@@ -44,8 +46,10 @@ export function DraftsButton({
}
const handleSaveAndOpen = async () => {
await onSaveDraft()
draftsDialogControl.open()
const {success} = await onSaveDraft()
if (success) {
draftsDialogControl.open()
}
}
const handleDiscardAndOpen = () => {
@@ -83,37 +87,51 @@ export function DraftsButton({
<Prompt.Outer control={savePromptControl}>
<Prompt.Content>
<Prompt.TitleText>
{isEditingDraft ? (
<Trans>Save changes?</Trans>
{canSaveDraft ? (
isEditingDraft ? (
<Trans>Save changes?</Trans>
) : (
<Trans>Save draft?</Trans>
)
) : (
<Trans>Save draft?</Trans>
<Trans>Discard draft?</Trans>
)}
</Prompt.TitleText>
</Prompt.Content>
<Prompt.DescriptionText>
{isEditingDraft ? (
<Trans>
You have unsaved changes. Would you like to save them before
viewing your drafts?
</Trans>
{canSaveDraft ? (
isEditingDraft ? (
<Trans>
You have unsaved changes. Would you like to save them before
viewing your drafts?
</Trans>
) : (
<Trans>
Would you like to save this as a draft before viewing your
drafts?
</Trans>
)
) : (
<Trans>
Would you like to save this as a draft before viewing your drafts?
You can only save drafts up to 1000 characters. Would you like to
discard this post before viewing your drafts?
</Trans>
)}
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={isEditingDraft ? _(msg`Save changes`) : _(msg`Save draft`)}
onPress={handleSaveAndOpen}
color="primary"
/>
{canSaveDraft && (
<Prompt.Action
cta={isEditingDraft ? _(msg`Save changes`) : _(msg`Save draft`)}
onPress={handleSaveAndOpen}
color="primary"
/>
)}
<Prompt.Action
cta={_(msg`Discard`)}
onPress={handleDiscardAndOpen}
color="negative_subtle"
/>
<Prompt.Cancel />
<Prompt.Cancel cta={_(msg`Keep editing`)} />
</Prompt.Actions>
</Prompt.Outer>
</>