fix: ask about a missing subject once, close an autosaved draft without asking and add Discard

This commit is contained in:
Linus Rath
2026-10-31 03:43:26 +01:00
parent 0647cbf171
commit 1cc4ccca8e
2 changed files with 99 additions and 7 deletions
@@ -13,6 +13,34 @@ describe('ComposeScreen wiring', () => {
expect(sheet.slice(0, 400)).toMatch(/onPick=\{\(identity\) => \{[^}]*setIdentityPickedByUser\(true\)/); 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', () => { 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')); const build = source.slice(source.indexOf('const buildOutgoing'), source.indexOf('// Save one draft version'));
expect(build).toMatch(/textBody = htmlToPlainText\(safeHtml, \{ mailMarkup: true \}\);/); expect(build).toMatch(/textBody = htmlToPlainText\(safeHtml, \{ mailMarkup: true \}\);/);
+71 -7
View File
@@ -11,7 +11,7 @@ import {
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote, List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
Users, Tag, Type, Highlighter, Users, Tag, Type, Highlighter, Trash2,
} from '../components/icons'; } from '../components/icons';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
@@ -1525,11 +1525,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
saveTimerRef.current = null; saveTimerRef.current = null;
} }
for (const a of attachments) a.abort?.abort(); for (const a of attachments) a.abort?.abort();
// A draft this session created by autosave goes with the discard; a // The draft goes with the discard: one this session autosaved, and a
// re-opened draft keeps its last saved version. // 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; const autosaved = draftIdRef.current;
// In another login that id names another message; the draft stays. // In another login that id names another message; the draft stays.
if (autosaved && !draft && !loginMoved()) { if (autosaved && !loginMoved()) {
void (async () => { void (async () => {
try { await inflightSaveRef.current; } catch { /* ignore */ } try { await inflightSaveRef.current; } catch { /* ignore */ }
const id = draftIdRef.current ?? autosaved; 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<boolean> => {
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 // The OS back gesture / hardware back goes through the same guard as the
// header X. `usePreventRemove` (not a bare `beforeRemove` listener) also // header X. `usePreventRemove` (not a bare `beforeRemove` listener) also
// blocks the iOS modal swipe-down, which is dismissed natively otherwise. // 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); navigation.dispatch(data.action);
return; 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 = () => { const onClose = () => {
navigation.goBack(); navigation.goBack();
}; };
@@ -2110,8 +2158,11 @@ export default function ComposeScreen({ route, navigation }: Props) {
}; };
// Confirm an empty subject instead of silently blocking Send (#684). // 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<boolean> => { const passesEmptySubjectCheck = (): Promise<boolean> => {
if (subject.trim() || !emptySubjectWarningEnabled) return Promise.resolve(true); if (subject.trim() || !emptySubjectWarningEnabled || emptySubjectConfirmedRef.current) return Promise.resolve(true);
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
Alert.alert( Alert.alert(
t('email_composer.empty_subject.title', 'Send without a subject?'), 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"), text: t('email_composer.empty_subject.dont_ask_again', "Don't ask again"),
onPress: () => { updateSetting('emptySubjectWarningEnabled', false); resolve(true); }, 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) {
)} )}
</View> </View>
<View style={styles.headerRight}> <View style={styles.headerRight}>
<Pressable
onPress={onDiscard}
style={styles.headerBtn}
hitSlop={8}
disabled={savingDraft}
accessibilityRole="button"
accessibilityLabel={t('email_composer.discard', 'Discard')}
>
<Trash2 size={20} color={c.text} />
</Pressable>
<Pressable <Pressable
onPress={() => setAttachMenuOpen(true)} onPress={() => setAttachMenuOpen(true)}
style={styles.headerBtn} style={styles.headerBtn}