[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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user