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 && (
+
+ )}
-
+
>