Bluesky app fork with some witchin' additions 💫

[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

authored by

Spence Pope and committed by
GitHub
2f156bb9 c46219bc

+148 -49
+2
src/lib/constants.ts
··· 62 62 63 63 export const MAX_GRAPHEME_LENGTH = 300 64 64 65 + export const MAX_DRAFT_GRAPHEME_LENGTH = 1000 66 + 65 67 export const MAX_DM_GRAPHEME_LENGTH = 1000 66 68 67 69 // Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
+109 -31
src/view/com/composer/Composer.tsx
··· 45 45 import * as FileSystem from 'expo-file-system' 46 46 import {type ImagePickerAsset} from 'expo-image-picker' 47 47 import { 48 + AppBskyDraftCreateDraft, 48 49 AppBskyUnspeccedDefs, 49 50 type AppBskyUnspeccedGetPostThreadV2, 50 51 AtUri, ··· 62 63 import {retry} from '#/lib/async/retry' 63 64 import {until} from '#/lib/async/until' 64 65 import { 66 + MAX_DRAFT_GRAPHEME_LENGTH, 65 67 MAX_GRAPHEME_LENGTH, 66 68 SUPPORTED_MIME_TYPES, 67 69 type SupportedMimeTypes, ··· 275 277 ) 276 278 277 279 const thread = composerState.thread 280 + 281 + // Clear error when composer content changes, but only if all posts are 282 + // back within the character limit. 283 + const allPostsWithinLimit = thread.posts.every( 284 + post => post.richtext.graphemeLength <= MAX_DRAFT_GRAPHEME_LENGTH, 285 + ) 286 + 278 287 const activePost = thread.posts[composerState.activePostIndex] 279 288 const nextPost: PostDraft | undefined = 280 289 thread.posts[composerState.activePostIndex + 1] ··· 543 552 revokeAllMediaUrls() 544 553 }, [closeComposer, queryClient]) 545 554 555 + const getDraftSaveError = React.useCallback( 556 + (e: unknown): string => { 557 + if (e instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) { 558 + return _(msg`You've reached the maximum number of drafts`) 559 + } 560 + return _(msg`Failed to save draft`) 561 + }, 562 + [_], 563 + ) 564 + 565 + const validateDraftTextOrError = React.useCallback((): boolean => { 566 + const tooLong = composerState.thread.posts.some( 567 + post => post.richtext.graphemeLength > MAX_DRAFT_GRAPHEME_LENGTH, 568 + ) 569 + if (tooLong) { 570 + setError( 571 + _( 572 + 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.'})}`, 573 + ), 574 + ) 575 + return false 576 + } 577 + return true 578 + }, [composerState.thread.posts, _]) 579 + 546 580 const handleSaveDraft = React.useCallback(async () => { 581 + setError('') 582 + if (!validateDraftTextOrError()) { 583 + return 584 + } 547 585 const isNewDraft = !composerState.draftId 548 586 try { 549 587 const result = await saveDraft({ ··· 569 607 onClose() 570 608 } catch (e) { 571 609 logger.error('Failed to save draft', {error: e}) 572 - setError(_(msg`Failed to save draft`)) 610 + setError(getDraftSaveError(e)) 573 611 } 574 - }, [saveDraft, composerState, composerDispatch, onClose, _, ax]) 612 + }, [ 613 + saveDraft, 614 + composerState, 615 + composerDispatch, 616 + onClose, 617 + ax, 618 + validateDraftTextOrError, 619 + getDraftSaveError, 620 + ]) 575 621 576 622 // Save without closing - for use by DraftsButton 577 - const saveCurrentDraft = React.useCallback(async () => { 578 - const result = await saveDraft({ 579 - composerState, 580 - existingDraftId: composerState.draftId, 581 - }) 582 - composerDispatch({type: 'mark_saved', draftId: result.draftId}) 583 - }, [saveDraft, composerState, composerDispatch]) 623 + const saveCurrentDraft = React.useCallback(async (): Promise<{ 624 + success: boolean 625 + }> => { 626 + setError('') 627 + if (!validateDraftTextOrError()) { 628 + return {success: false} 629 + } 630 + try { 631 + const result = await saveDraft({ 632 + composerState, 633 + existingDraftId: composerState.draftId, 634 + }) 635 + composerDispatch({type: 'mark_saved', draftId: result.draftId}) 636 + return {success: true} 637 + } catch (e) { 638 + setError(getDraftSaveError(e)) 639 + return {success: false} 640 + } 641 + }, [ 642 + saveDraft, 643 + composerState, 644 + composerDispatch, 645 + validateDraftTextOrError, 646 + getDraftSaveError, 647 + ]) 584 648 585 649 // Handle discard action - fires metric and closes composer 586 650 const handleDiscard = React.useCallback(() => { ··· 1090 1154 isEmpty={isComposerEmpty} 1091 1155 isDirty={composerState.isDirty} 1092 1156 isEditingDraft={!!composerState.draftId} 1157 + canSaveDraft={allPostsWithinLimit} 1093 1158 textLength={thread.posts[0].richtext.text.length}> 1094 1159 {missingAltError && <AltTextReminder error={missingAltError} />} 1095 1160 <ErrorBanner ··· 1154 1219 <Prompt.Outer control={discardPromptControl}> 1155 1220 <Prompt.Content> 1156 1221 <Prompt.TitleText> 1157 - {composerState.draftId ? ( 1158 - <Trans>Save changes?</Trans> 1222 + {allPostsWithinLimit ? ( 1223 + composerState.draftId ? ( 1224 + <Trans>Save changes?</Trans> 1225 + ) : ( 1226 + <Trans>Save draft?</Trans> 1227 + ) 1159 1228 ) : ( 1160 - <Trans>Save draft?</Trans> 1229 + <Trans>Discard post?</Trans> 1161 1230 )} 1162 1231 </Prompt.TitleText> 1163 1232 <Prompt.DescriptionText> 1164 - {composerState.draftId ? ( 1165 - <Trans> 1166 - You have unsaved changes to this draft, would you like to 1167 - save them? 1168 - </Trans> 1233 + {allPostsWithinLimit ? ( 1234 + composerState.draftId ? ( 1235 + <Trans> 1236 + You have unsaved changes to this draft, would you like to 1237 + save them? 1238 + </Trans> 1239 + ) : ( 1240 + <Trans> 1241 + Would you like to save this as a draft to edit later? 1242 + </Trans> 1243 + ) 1169 1244 ) : ( 1170 - <Trans> 1171 - Would you like to save this as a draft to edit later? 1172 - </Trans> 1245 + <Trans>You can only save drafts up to 1000 characters.</Trans> 1173 1246 )} 1174 1247 </Prompt.DescriptionText> 1175 1248 </Prompt.Content> 1176 1249 <Prompt.Actions> 1177 - <Prompt.Action 1178 - cta={ 1179 - composerState.draftId 1180 - ? _(msg`Save changes`) 1181 - : _(msg`Save draft`) 1182 - } 1183 - onPress={handleSaveDraft} 1184 - color="primary" 1185 - /> 1250 + {allPostsWithinLimit && ( 1251 + <Prompt.Action 1252 + cta={ 1253 + composerState.draftId 1254 + ? _(msg`Save changes`) 1255 + : _(msg`Save draft`) 1256 + } 1257 + onPress={handleSaveDraft} 1258 + color="primary" 1259 + /> 1260 + )} 1186 1261 <Prompt.Action 1187 1262 cta={_(msg`Discard`)} 1188 1263 onPress={handleDiscard} 1189 1264 color="negative_subtle" 1190 1265 /> 1191 - <Prompt.Cancel /> 1266 + <Prompt.Cancel cta={_(msg`Keep editing`)} /> 1192 1267 </Prompt.Actions> 1193 1268 </Prompt.Outer> 1194 1269 )} ··· 1416 1491 isEmpty, 1417 1492 isDirty, 1418 1493 isEditingDraft, 1494 + canSaveDraft, 1419 1495 textLength, 1420 1496 topBarAnimatedStyle, 1421 1497 children, ··· 1429 1505 onCancel: () => void 1430 1506 onPublish: () => void 1431 1507 onSelectDraft: (draft: DraftSummary) => void 1432 - onSaveDraft: () => Promise<void> 1508 + onSaveDraft: () => Promise<{success: boolean}> 1433 1509 onDiscard: () => void 1434 1510 isEmpty: boolean 1435 1511 isDirty: boolean 1436 1512 isEditingDraft: boolean 1513 + canSaveDraft: boolean 1437 1514 textLength: number 1438 1515 topBarAnimatedStyle: StyleProp<ViewStyle> 1439 1516 children?: React.ReactNode ··· 1481 1558 isEmpty={isEmpty} 1482 1559 isDirty={isDirty} 1483 1560 isEditingDraft={isEditingDraft} 1561 + canSaveDraft={canSaveDraft} 1484 1562 textLength={textLength} 1485 1563 /> 1486 1564 )}
+1
src/view/com/composer/drafts/DraftItem.tsx
··· 98 98 {!!post.text.trim().length && ( 99 99 <RichText 100 100 style={[a.text_md, a.leading_snug, a.pointer_events_none]} 101 + numberOfLines={8} 101 102 value={post.text} 102 103 enableTags 103 104 disableMentionFacetValidation
+36 -18
src/view/com/composer/drafts/DraftsButton.tsx
··· 17 17 isEmpty, 18 18 isDirty, 19 19 isEditingDraft, 20 + canSaveDraft, 20 21 textLength, 21 22 }: { 22 23 onSelectDraft: (draft: DraftSummary) => void 23 - onSaveDraft: () => Promise<void> 24 + onSaveDraft: () => Promise<{success: boolean}> 24 25 onDiscard: () => void 25 26 isEmpty: boolean 26 27 isDirty: boolean 27 28 isEditingDraft: boolean 29 + canSaveDraft: boolean 28 30 textLength: number 29 31 }) { 30 32 const {_} = useLingui() ··· 44 46 } 45 47 46 48 const handleSaveAndOpen = async () => { 47 - await onSaveDraft() 48 - draftsDialogControl.open() 49 + const {success} = await onSaveDraft() 50 + if (success) { 51 + draftsDialogControl.open() 52 + } 49 53 } 50 54 51 55 const handleDiscardAndOpen = () => { ··· 83 87 <Prompt.Outer control={savePromptControl}> 84 88 <Prompt.Content> 85 89 <Prompt.TitleText> 86 - {isEditingDraft ? ( 87 - <Trans>Save changes?</Trans> 90 + {canSaveDraft ? ( 91 + isEditingDraft ? ( 92 + <Trans>Save changes?</Trans> 93 + ) : ( 94 + <Trans>Save draft?</Trans> 95 + ) 88 96 ) : ( 89 - <Trans>Save draft?</Trans> 97 + <Trans>Discard draft?</Trans> 90 98 )} 91 99 </Prompt.TitleText> 92 100 </Prompt.Content> 93 101 <Prompt.DescriptionText> 94 - {isEditingDraft ? ( 95 - <Trans> 96 - You have unsaved changes. Would you like to save them before 97 - viewing your drafts? 98 - </Trans> 102 + {canSaveDraft ? ( 103 + isEditingDraft ? ( 104 + <Trans> 105 + You have unsaved changes. Would you like to save them before 106 + viewing your drafts? 107 + </Trans> 108 + ) : ( 109 + <Trans> 110 + Would you like to save this as a draft before viewing your 111 + drafts? 112 + </Trans> 113 + ) 99 114 ) : ( 100 115 <Trans> 101 - Would you like to save this as a draft before viewing your drafts? 116 + You can only save drafts up to 1000 characters. Would you like to 117 + discard this post before viewing your drafts? 102 118 </Trans> 103 119 )} 104 120 </Prompt.DescriptionText> 105 121 <Prompt.Actions> 106 - <Prompt.Action 107 - cta={isEditingDraft ? _(msg`Save changes`) : _(msg`Save draft`)} 108 - onPress={handleSaveAndOpen} 109 - color="primary" 110 - /> 122 + {canSaveDraft && ( 123 + <Prompt.Action 124 + cta={isEditingDraft ? _(msg`Save changes`) : _(msg`Save draft`)} 125 + onPress={handleSaveAndOpen} 126 + color="primary" 127 + /> 128 + )} 111 129 <Prompt.Action 112 130 cta={_(msg`Discard`)} 113 131 onPress={handleDiscardAndOpen} 114 132 color="negative_subtle" 115 133 /> 116 - <Prompt.Cancel /> 134 + <Prompt.Cancel cta={_(msg`Keep editing`)} /> 117 135 </Prompt.Actions> 118 136 </Prompt.Outer> 119 137 </>