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