From 2f156bb9067bd968a089426a85dfa07a39ff07a7 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 12 Feb 2026 10:24:38 -0500 Subject: [PATCH] [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 --- src/lib/constants.ts | 2 + src/view/com/composer/Composer.tsx | 140 ++++++++++++++---- src/view/com/composer/drafts/DraftItem.tsx | 1 + src/view/com/composer/drafts/DraftsButton.tsx | 54 ++++--- 4 files changed, 148 insertions(+), 49 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 96e1d8e2e8..ba19765d47 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 425440609b..53d50bca8a 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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 && } - {composerState.draftId ? ( - Save changes? + {allPostsWithinLimit ? ( + composerState.draftId ? ( + Save changes? + ) : ( + Save draft? + ) ) : ( - Save draft? + Discard post? )} - {composerState.draftId ? ( - - You have unsaved changes to this draft, would you like to - save them? - + {allPostsWithinLimit ? ( + composerState.draftId ? ( + + You have unsaved changes to this draft, would you like to + save them? + + ) : ( + + Would you like to save this as a draft to edit later? + + ) ) : ( - - Would you like to save this as a draft to edit later? - + You can only save drafts up to 1000 characters. )} - + {allPostsWithinLimit && ( + + )} - + )} @@ -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 + onSaveDraft: () => Promise<{success: boolean}> onDiscard: () => void isEmpty: boolean isDirty: boolean isEditingDraft: boolean + canSaveDraft: boolean textLength: number topBarAnimatedStyle: StyleProp children?: React.ReactNode @@ -1481,6 +1558,7 @@ function ComposerTopBar({ isEmpty={isEmpty} isDirty={isDirty} isEditingDraft={isEditingDraft} + canSaveDraft={canSaveDraft} textLength={textLength} /> )} diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx index 964afe1180..16274acbf4 100644 --- a/src/view/com/composer/drafts/DraftItem.tsx +++ b/src/view/com/composer/drafts/DraftItem.tsx @@ -98,6 +98,7 @@ export function DraftItem({ {!!post.text.trim().length && ( void - onSaveDraft: () => Promise + 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({ - {isEditingDraft ? ( - Save changes? + {canSaveDraft ? ( + isEditingDraft ? ( + Save changes? + ) : ( + Save draft? + ) ) : ( - Save draft? + Discard draft? )} - {isEditingDraft ? ( - - You have unsaved changes. Would you like to save them before - viewing your drafts? - + {canSaveDraft ? ( + isEditingDraft ? ( + + You have unsaved changes. Would you like to save them before + viewing your drafts? + + ) : ( + + Would you like to save this as a draft before viewing your + drafts? + + ) ) : ( - 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? )} - + {canSaveDraft && ( + + )} - +