mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: ask about a missing subject once, close an autosaved draft without asking and add Discard
This commit is contained in:
@@ -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 \}\);/);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user