[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_GRAPHEME_LENGTH = 300
export const MAX_DRAFT_GRAPHEME_LENGTH = 1000
export const MAX_DM_GRAPHEME_LENGTH = 1000 export const MAX_DM_GRAPHEME_LENGTH = 1000
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html // 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 * as FileSystem from 'expo-file-system'
import {type ImagePickerAsset} from 'expo-image-picker' import {type ImagePickerAsset} from 'expo-image-picker'
import { import {
AppBskyDraftCreateDraft,
AppBskyUnspeccedDefs, AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadV2, type AppBskyUnspeccedGetPostThreadV2,
AtUri, AtUri,
@@ -62,6 +63,7 @@ import {useAppState} from '#/lib/appState'
import {retry} from '#/lib/async/retry' import {retry} from '#/lib/async/retry'
import {until} from '#/lib/async/until' import {until} from '#/lib/async/until'
import { import {
MAX_DRAFT_GRAPHEME_LENGTH,
MAX_GRAPHEME_LENGTH, MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES, SUPPORTED_MIME_TYPES,
type SupportedMimeTypes, type SupportedMimeTypes,
@@ -275,6 +277,13 @@ export const ComposePost = ({
) )
const thread = composerState.thread 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 activePost = thread.posts[composerState.activePostIndex]
const nextPost: PostDraft | undefined = const nextPost: PostDraft | undefined =
thread.posts[composerState.activePostIndex + 1] thread.posts[composerState.activePostIndex + 1]
@@ -543,7 +552,36 @@ export const ComposePost = ({
revokeAllMediaUrls() revokeAllMediaUrls()
}, [closeComposer, queryClient]) }, [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 () => { const handleSaveDraft = React.useCallback(async () => {
setError('')
if (!validateDraftTextOrError()) {
return
}
const isNewDraft = !composerState.draftId const isNewDraft = !composerState.draftId
try { try {
const result = await saveDraft({ const result = await saveDraft({
@@ -569,18 +607,44 @@ export const ComposePost = ({
onClose() onClose()
} catch (e) { } catch (e) {
logger.error('Failed to save draft', {error: 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 // Save without closing - for use by DraftsButton
const saveCurrentDraft = React.useCallback(async () => { const saveCurrentDraft = React.useCallback(async (): Promise<{
const result = await saveDraft({ success: boolean
composerState, }> => {
existingDraftId: composerState.draftId, setError('')
}) if (!validateDraftTextOrError()) {
composerDispatch({type: 'mark_saved', draftId: result.draftId}) return {success: false}
}, [saveDraft, composerState, composerDispatch]) }
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 // Handle discard action - fires metric and closes composer
const handleDiscard = React.useCallback(() => { const handleDiscard = React.useCallback(() => {
@@ -1090,6 +1154,7 @@ export const ComposePost = ({
isEmpty={isComposerEmpty} isEmpty={isComposerEmpty}
isDirty={composerState.isDirty} isDirty={composerState.isDirty}
isEditingDraft={!!composerState.draftId} isEditingDraft={!!composerState.draftId}
canSaveDraft={allPostsWithinLimit}
textLength={thread.posts[0].richtext.text.length}> textLength={thread.posts[0].richtext.text.length}>
{missingAltError && <AltTextReminder error={missingAltError} />} {missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner <ErrorBanner
@@ -1154,41 +1219,51 @@ export const ComposePost = ({
<Prompt.Outer control={discardPromptControl}> <Prompt.Outer control={discardPromptControl}>
<Prompt.Content> <Prompt.Content>
<Prompt.TitleText> <Prompt.TitleText>
{composerState.draftId ? ( {allPostsWithinLimit ? (
<Trans>Save changes?</Trans> composerState.draftId ? (
<Trans>Save changes?</Trans>
) : (
<Trans>Save draft?</Trans>
)
) : ( ) : (
<Trans>Save draft?</Trans> <Trans>Discard post?</Trans>
)} )}
</Prompt.TitleText> </Prompt.TitleText>
<Prompt.DescriptionText> <Prompt.DescriptionText>
{composerState.draftId ? ( {allPostsWithinLimit ? (
<Trans> composerState.draftId ? (
You have unsaved changes to this draft, would you like to <Trans>
save them? You have unsaved changes to this draft, would you like to
</Trans> save them?
</Trans>
) : (
<Trans>
Would you like to save this as a draft to edit later?
</Trans>
)
) : ( ) : (
<Trans> <Trans>You can only save drafts up to 1000 characters.</Trans>
Would you like to save this as a draft to edit later?
</Trans>
)} )}
</Prompt.DescriptionText> </Prompt.DescriptionText>
</Prompt.Content> </Prompt.Content>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action {allPostsWithinLimit && (
cta={ <Prompt.Action
composerState.draftId cta={
? _(msg`Save changes`) composerState.draftId
: _(msg`Save draft`) ? _(msg`Save changes`)
} : _(msg`Save draft`)
onPress={handleSaveDraft} }
color="primary" onPress={handleSaveDraft}
/> color="primary"
/>
)}
<Prompt.Action <Prompt.Action
cta={_(msg`Discard`)} cta={_(msg`Discard`)}
onPress={handleDiscard} onPress={handleDiscard}
color="negative_subtle" color="negative_subtle"
/> />
<Prompt.Cancel /> <Prompt.Cancel cta={_(msg`Keep editing`)} />
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </Prompt.Outer>
)} )}
@@ -1416,6 +1491,7 @@ function ComposerTopBar({
isEmpty, isEmpty,
isDirty, isDirty,
isEditingDraft, isEditingDraft,
canSaveDraft,
textLength, textLength,
topBarAnimatedStyle, topBarAnimatedStyle,
children, children,
@@ -1429,11 +1505,12 @@ function ComposerTopBar({
onCancel: () => void onCancel: () => void
onPublish: () => void onPublish: () => void
onSelectDraft: (draft: DraftSummary) => void onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<void> onSaveDraft: () => Promise<{success: boolean}>
onDiscard: () => void onDiscard: () => void
isEmpty: boolean isEmpty: boolean
isDirty: boolean isDirty: boolean
isEditingDraft: boolean isEditingDraft: boolean
canSaveDraft: boolean
textLength: number textLength: number
topBarAnimatedStyle: StyleProp<ViewStyle> topBarAnimatedStyle: StyleProp<ViewStyle>
children?: React.ReactNode children?: React.ReactNode
@@ -1481,6 +1558,7 @@ function ComposerTopBar({
isEmpty={isEmpty} isEmpty={isEmpty}
isDirty={isDirty} isDirty={isDirty}
isEditingDraft={isEditingDraft} isEditingDraft={isEditingDraft}
canSaveDraft={canSaveDraft}
textLength={textLength} textLength={textLength}
/> />
)} )}
@@ -98,6 +98,7 @@ export function DraftItem({
{!!post.text.trim().length && ( {!!post.text.trim().length && (
<RichText <RichText
style={[a.text_md, a.leading_snug, a.pointer_events_none]} style={[a.text_md, a.leading_snug, a.pointer_events_none]}
numberOfLines={8}
value={post.text} value={post.text}
enableTags enableTags
disableMentionFacetValidation disableMentionFacetValidation
+36 -18
View File
@@ -17,14 +17,16 @@ export function DraftsButton({
isEmpty, isEmpty,
isDirty, isDirty,
isEditingDraft, isEditingDraft,
canSaveDraft,
textLength, textLength,
}: { }: {
onSelectDraft: (draft: DraftSummary) => void onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<void> onSaveDraft: () => Promise<{success: boolean}>
onDiscard: () => void onDiscard: () => void
isEmpty: boolean isEmpty: boolean
isDirty: boolean isDirty: boolean
isEditingDraft: boolean isEditingDraft: boolean
canSaveDraft: boolean
textLength: number textLength: number
}) { }) {
const {_} = useLingui() const {_} = useLingui()
@@ -44,8 +46,10 @@ export function DraftsButton({
} }
const handleSaveAndOpen = async () => { const handleSaveAndOpen = async () => {
await onSaveDraft() const {success} = await onSaveDraft()
draftsDialogControl.open() if (success) {
draftsDialogControl.open()
}
} }
const handleDiscardAndOpen = () => { const handleDiscardAndOpen = () => {
@@ -83,37 +87,51 @@ export function DraftsButton({
<Prompt.Outer control={savePromptControl}> <Prompt.Outer control={savePromptControl}>
<Prompt.Content> <Prompt.Content>
<Prompt.TitleText> <Prompt.TitleText>
{isEditingDraft ? ( {canSaveDraft ? (
<Trans>Save changes?</Trans> isEditingDraft ? (
<Trans>Save changes?</Trans>
) : (
<Trans>Save draft?</Trans>
)
) : ( ) : (
<Trans>Save draft?</Trans> <Trans>Discard draft?</Trans>
)} )}
</Prompt.TitleText> </Prompt.TitleText>
</Prompt.Content> </Prompt.Content>
<Prompt.DescriptionText> <Prompt.DescriptionText>
{isEditingDraft ? ( {canSaveDraft ? (
<Trans> isEditingDraft ? (
You have unsaved changes. Would you like to save them before <Trans>
viewing your drafts? You have unsaved changes. Would you like to save them before
</Trans> viewing your drafts?
</Trans>
) : (
<Trans>
Would you like to save this as a draft before viewing your
drafts?
</Trans>
)
) : ( ) : (
<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> </Trans>
)} )}
</Prompt.DescriptionText> </Prompt.DescriptionText>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action {canSaveDraft && (
cta={isEditingDraft ? _(msg`Save changes`) : _(msg`Save draft`)} <Prompt.Action
onPress={handleSaveAndOpen} cta={isEditingDraft ? _(msg`Save changes`) : _(msg`Save draft`)}
color="primary" onPress={handleSaveAndOpen}
/> color="primary"
/>
)}
<Prompt.Action <Prompt.Action
cta={_(msg`Discard`)} cta={_(msg`Discard`)}
onPress={handleDiscardAndOpen} onPress={handleDiscardAndOpen}
color="negative_subtle" color="negative_subtle"
/> />
<Prompt.Cancel /> <Prompt.Cancel cta={_(msg`Keep editing`)} />
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </Prompt.Outer>
</> </>