mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +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\)/);
|
||||
});
|
||||
|
||||
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 \}\);/);
|
||||
|
||||
@@ -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<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
|
||||
// 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<boolean> => {
|
||||
if (subject.trim() || !emptySubjectWarningEnabled) return Promise.resolve(true);
|
||||
if (subject.trim() || !emptySubjectWarningEnabled || emptySubjectConfirmedRef.current) return Promise.resolve(true);
|
||||
return new Promise<boolean>((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) {
|
||||
)}
|
||||
</View>
|
||||
<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
|
||||
onPress={() => setAttachMenuOpen(true)}
|
||||
style={styles.headerBtn}
|
||||
|
||||
Reference in New Issue
Block a user