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