diff --git a/src/lib/__tests__/compose-screen-wiring.test.ts b/src/lib/__tests__/compose-screen-wiring.test.ts index 9a7b854f..f992a9da 100644 --- a/src/lib/__tests__/compose-screen-wiring.test.ts +++ b/src/lib/__tests__/compose-screen-wiring.test.ts @@ -13,6 +13,34 @@ describe('ComposeScreen wiring', () => { expect(sheet.slice(0, 400)).toMatch(/onPick=\{\(identity\) => \{[^}]*setIdentityPickedByUser\(true\)/); }); + it('asks about a missing subject once per message', () => { + const check = source.slice(source.indexOf('const passesEmptySubjectCheck'), source.indexOf('const confirmedPlaceholdersRef')); + expect(check).toMatch(/if \(subject\.trim\(\) \|\| !emptySubjectWarningEnabled \|\| emptySubjectConfirmedRef\.current\) return Promise\.resolve\(true\);/); + expect(check).toMatch(/send_anyway[\s\S]*?onPress: \(\) => \{ emptySubjectConfirmedRef\.current = true; resolve\(true\); \}/); + }); + + it('closes without "unsaved changes" when the saved draft holds every change', () => { + const guard = source.slice(source.indexOf('usePreventRemove(')); + const end = guard.indexOf('});\n') > -1 ? guard.indexOf('});\n') : guard.indexOf('});\r\n'); + const body = guard.slice(0, end); + expect(body.indexOf('await savedAlready()')).toBeGreaterThan(-1); + expect(body.indexOf('await savedAlready()')).toBeLessThan(body.indexOf('showCloseDialog(')); + const saved = source.slice(source.indexOf('const savedAlready'), source.indexOf('usePreventRemove(')); + // The editor's own content, compared with the last saved snapshot. + expect(saved).toMatch(/editorRef\.current\?\.getHtml\(\)/); + expect(saved).toMatch(/return snapshot === lastSavedRef\.current;/); + }); + + it('offers Discard, which deletes a re-opened draft too', () => { + const discard = source.slice(source.indexOf('const discardAndClose'), source.indexOf('const showCloseDialog')); + expect(discard).toMatch(/if \(autosaved && !loginMoved\(\)\) \{/); + expect(discard).not.toMatch(/!draft\b/); + const onDiscard = source.slice(source.indexOf('const onDiscard')); + expect(onDiscard).toMatch(/discard_draft_message/); + expect(onDiscard).toMatch(/onPress: \(\) => discardAndClose\(/); + expect(source).toMatch(/onPress=\{onDiscard\}/); + }); + it('writes the text part of a rich message with mail markup', () => { const build = source.slice(source.indexOf('const buildOutgoing'), source.indexOf('// Save one draft version')); expect(build).toMatch(/textBody = htmlToPlainText\(safeHtml, \{ mailMarkup: true \}\);/); diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 71f047d4..db5f1cb8 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -11,7 +11,7 @@ import { List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, - Users, Tag, Type, Highlighter, + Users, Tag, Type, Highlighter, Trash2, } from '../components/icons'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as ImagePicker from 'expo-image-picker'; @@ -1525,11 +1525,13 @@ export default function ComposeScreen({ route, navigation }: Props) { saveTimerRef.current = null; } for (const a of attachments) a.abort?.abort(); - // A draft this session created by autosave goes with the discard; a - // re-opened draft keeps its last saved version. + // The draft goes with the discard: one this session autosaved, and a + // re-opened one too (webmail handleDiscardAndClose). Keeping the latest + // autosaved version of a re-opened draft kept the very edits the user + // discarded. const autosaved = draftIdRef.current; // In another login that id names another message; the draft stays. - if (autosaved && !draft && !loginMoved()) { + if (autosaved && !loginMoved()) { void (async () => { try { await inflightSaveRef.current; } catch { /* ignore */ } const id = draftIdRef.current ?? autosaved; @@ -1563,6 +1565,26 @@ export default function ComposeScreen({ route, navigation }: Props) { ); }; + // Every change is in the saved draft: closing loses nothing, so it asks + // nothing (webmail handleClose savedAlready). Read from the editor itself, + // whose change messages can lag the last keystrokes. + const savedAlready = async (): Promise => { + if (!draftIdRef.current || !lastSavedRef.current || draftStatus === 'failed') return false; + let html = latestRef.current.bodyHtml; + if (!plainTextMode) { + try { + html = (await editorRef.current?.getHtml()) ?? html; + } catch { + return false; + } + } + const snapshot = snapshotOf({ + to: finalTo, cc: finalCc, bcc: finalBcc, subject, + body: plainTextMode ? latestRef.current.plainBody : html, attachments, + }); + return snapshot === lastSavedRef.current; + }; + // The OS back gesture / hardware back goes through the same guard as the // header X. `usePreventRemove` (not a bare `beforeRemove` listener) also // blocks the iOS modal swipe-down, which is dismissed natively otherwise. @@ -1572,9 +1594,35 @@ export default function ComposeScreen({ route, navigation }: Props) { navigation.dispatch(data.action); return; } - showCloseDialog(() => navigation.dispatch(data.action)); + void (async () => { + if (await savedAlready()) { + allowLeaveRef.current = true; + navigation.dispatch(data.action); + return; + } + showCloseDialog(() => navigation.dispatch(data.action)); + })(); }); + // Discard deletes the draft, also one without unsaved changes such as a + // re-opened draft or a scheduled message back for editing, which the X + // closes and keeps (webmail handleDiscardClick). + const onDiscard = () => { + if (!isDirty && !needsSave && !draftIdRef.current) { + allowLeaveRef.current = true; + navigation.goBack(); + return; + } + Alert.alert( + t('email_composer.discard_draft_title', 'Discard draft?'), + t('email_composer.discard_draft_message', 'The draft will be deleted.'), + [ + { text: t('email_composer.cancel', 'Cancel'), style: 'cancel' }, + { text: t('email_composer.discard', 'Discard'), style: 'destructive', onPress: () => discardAndClose(() => navigation.goBack()) }, + ], + ); + }; + const onClose = () => { navigation.goBack(); }; @@ -2110,8 +2158,11 @@ export default function ComposeScreen({ route, navigation }: Props) { }; // Confirm an empty subject instead of silently blocking Send (#684). + // Asked once: after "Send anyway" and a trip back from a later prompt, the + // next Send doesn't ask again (webmail emptySubjectConfirmedRef). + const emptySubjectConfirmedRef = React.useRef(false); const passesEmptySubjectCheck = (): Promise => { - if (subject.trim() || !emptySubjectWarningEnabled) return Promise.resolve(true); + if (subject.trim() || !emptySubjectWarningEnabled || emptySubjectConfirmedRef.current) return Promise.resolve(true); return new Promise((resolve) => { Alert.alert( t('email_composer.empty_subject.title', 'Send without a subject?'), @@ -2122,7 +2173,10 @@ export default function ComposeScreen({ route, navigation }: Props) { text: t('email_composer.empty_subject.dont_ask_again', "Don't ask again"), onPress: () => { updateSetting('emptySubjectWarningEnabled', false); resolve(true); }, }, - { text: t('email_composer.empty_subject.send_anyway', 'Send anyway'), onPress: () => resolve(true) }, + { + text: t('email_composer.empty_subject.send_anyway', 'Send anyway'), + onPress: () => { emptySubjectConfirmedRef.current = true; resolve(true); }, + }, ], ); }); @@ -2609,6 +2663,16 @@ export default function ComposeScreen({ route, navigation }: Props) { )} + + + setAttachMenuOpen(true)} style={styles.headerBtn}