From 92b42e4a4ff91f634125c6186736435bab03a5ee Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:34:11 +0200 Subject: [PATCH] fix: keep the composer open when its draft can't be saved "Save draft" returned early without saving when the identities hadn't loaded (offline when the composer opened) or the account had no Drafts folder, and the close dialog then closed the composer as if the save had worked, dropping the text. The close dialog now offers only Cancel and Discard in that case and says why the draft can't be saved; the save path refuses to close too. The close guard moves from a bare `beforeRemove` listener to `usePreventRemove`, which also stops the iOS modal swipe-down that the listener could not prevent. --- locales/rn/en.json | 2 ++ src/screens/ComposeScreen.tsx | 47 +++++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index b7294771..be72b543 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -116,6 +116,8 @@ "recipient_move_to": "Move to {field}", "recipient_remove": "Remove", "save_draft": "Save Draft", + "save_failed_no_drafts": "There is no Drafts folder to save this draft to.", + "save_failed_no_identity": "Your sender identities could not be loaded, so this draft cannot be saved. Check your connection, or copy your text before closing.", "schedule_custom": "Pick date & time…", "schedule_in_1h": "In 1 hour", "schedule_in_3h": "In 3 hours", diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 5afda5eb..d1cf2888 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -4,6 +4,7 @@ import { Keyboard, Dimensions, Platform, ActivityIndicator, Alert, Modal, Switch, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { usePreventRemove } from '@react-navigation/native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough, @@ -1279,11 +1280,25 @@ export default function ComposeScreen({ route, navigation }: Props) { // ── Close guard ────────────────────────────────────────────────────── + // Why a draft can't be saved right now: the identities didn't load (e.g. + // offline when the composer opened) or the account has no Drafts folder. + const draftUnsavableReason = !primaryIdentity + ? t('email_composer.save_failed_no_identity', 'Your sender identities could not be loaded, so this draft cannot be saved. Check your connection, or copy your text before closing.') + : !draftsMailbox + ? t('email_composer.save_failed_no_drafts', 'There is no Drafts folder to save this draft to.') + : null; + const saveAndClose = async (proceed: () => void) => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } + // Closing after a save that couldn't run would drop the text as if it + // had been saved: stay open and say why. + if (draftUnsavableReason) { + Alert.alert(t('email_composer.save_failed', 'Failed to save'), draftUnsavableReason); + return; + } setSavingDraft(true); try { await saveDraft({ live: true }); @@ -1322,6 +1337,17 @@ export default function ComposeScreen({ route, navigation }: Props) { }; const showCloseDialog = (proceed: () => void) => { + if (draftUnsavableReason) { + Alert.alert( + t('email_composer.discard_draft_title', 'Discard draft?'), + draftUnsavableReason, + [ + { text: t('email_composer.cancel', 'Cancel'), style: 'cancel' }, + { text: t('email_composer.discard', 'Discard'), style: 'destructive', onPress: () => discardAndClose(proceed) }, + ], + ); + return; + } Alert.alert( t('email_composer.close_draft_title', 'Save or discard draft?'), t('email_composer.close_draft_message', 'You have unsaved changes. Would you like to save this as a draft or discard it?'), @@ -1332,19 +1358,18 @@ export default function ComposeScreen({ route, navigation }: Props) { ], ); }; - const showCloseDialogRef = React.useRef(showCloseDialog); - showCloseDialogRef.current = showCloseDialog; // The OS back gesture / hardware back goes through the same guard as the - // header X. - React.useEffect(() => { - return navigation.addListener('beforeRemove', (e) => { - if (allowLeaveRef.current) return; - if (!latestRef.current.isDirty && !latestRef.current.needsSave) return; - e.preventDefault(); - showCloseDialogRef.current(() => navigation.dispatch(e.data.action)); - }); - }, [navigation]); + // header X. `usePreventRemove` (not a bare `beforeRemove` listener) also + // blocks the iOS modal swipe-down, which is dismissed natively otherwise. + // Re-dispatching the intercepted action doesn't come back here. + usePreventRemove(isDirty || needsSave, ({ data }) => { + if (allowLeaveRef.current) { + navigation.dispatch(data.action); + return; + } + showCloseDialog(() => navigation.dispatch(data.action)); + }); const onClose = () => { navigation.goBack();