mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: warn before sending a message with unfilled placeholders
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// ─── Heavy component mocks (mirrors empty-subject-warning.test.tsx) ──────────
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
placeholderWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
plainTextBodyWithoutSignature: (body: string) => body,
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A ready-to-send draft whose body still has a fill-in. */
|
||||
const DRAFT_WITH_PLACEHOLDER = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Lunch',
|
||||
body: '<p>See you at [TIME] in TODO.</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement;
|
||||
|
||||
describe('composer placeholder warning', () => {
|
||||
beforeEach(() => {
|
||||
updateSetting.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({
|
||||
placeholderWarningEnabled: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('asks before sending and lists the placeholders', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('placeholder_warning.title')).toBeInTheDocument();
|
||||
const found = within(dialog).getAllByRole('listitem').map((li) => li.textContent);
|
||||
expect(found).toEqual(['[TIME]', 'TODO']);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends once confirmed', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns the warning off when "don\'t ask again" is checked', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(await screen.findByText('placeholder_warning.dont_ask_again'));
|
||||
fireEvent.click(screen.getByText('placeholder_warning.send_anyway'));
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(updateSetting).toHaveBeenCalledWith('placeholderWarningEnabled', false);
|
||||
});
|
||||
|
||||
it('goes back to editing with the placeholder selected', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<EmailComposer
|
||||
initialData={{ ...DRAFT_WITH_PLACEHOLDER, subject: 'Hi {{name}}', body: '<p>Hello</p>' }}
|
||||
onSend={onSend}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(await screen.findByText('placeholder_warning.back'));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
|
||||
const subject = screen.getByDisplayValue('Hi {{name}}') as HTMLInputElement;
|
||||
await waitFor(() => expect(document.activeElement).toBe(subject));
|
||||
expect([subject.selectionStart, subject.selectionEnd]).toEqual([3, 11]);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not ask again after Send anyway and a trip back to add an attachment', async () => {
|
||||
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
|
||||
// The body says "See you", so the attachment reminder stops the send.
|
||||
fireEvent.click(await screen.findByText('forgot_attachment.back'));
|
||||
await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument();
|
||||
expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks again when a new placeholder shows up', async () => {
|
||||
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
|
||||
fireEvent.click(await screen.findByText('forgot_attachment.back'));
|
||||
await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: 'Lunch with {{name}}' } });
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('placeholder_warning.title')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('{{name}}')).toBeInTheDocument();
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores placeholders in quoted text and in the signature', async () => {
|
||||
const onSend = vi.fn();
|
||||
const body = '<p>Thanks!</p>'
|
||||
+ '<div data-signature-block-node="">[COMPANY]</div>'
|
||||
+ '<blockquote><p>Meet at [TIME]? TODO</p></blockquote>';
|
||||
render(<EmailComposer initialData={{ ...DRAFT_WITH_PLACEHOLDER, body }} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends without asking when the warning is off', async () => {
|
||||
useSettingsStore.setState({ placeholderWarningEnabled: false } as never);
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
|
||||
|
||||
fireEvent.click(sendButton());
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,7 @@ import { usePluginSignature } from "@/hooks/use-plugin-signature";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
|
||||
import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from "@/lib/placeholder-check";
|
||||
import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
@@ -478,6 +479,7 @@ export function EmailComposer({
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
const emptySubjectWarningEnabled = useSettingsStore((state) => state.emptySubjectWarningEnabled);
|
||||
const placeholderWarningEnabled = useSettingsStore((state) => state.placeholderWarningEnabled);
|
||||
const recipientMentionsEnabled = useSettingsStore((state) => state.recipientMentionsEnabled);
|
||||
const mentionAddsRecipientTo = useSettingsStore((state) => state.mentionAddsRecipientTo);
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
@@ -966,6 +968,10 @@ export function EmailComposer({
|
||||
const [showEmptySubjectWarning, setShowEmptySubjectWarning] = useState(false);
|
||||
const [emptySubjectDelayedUntil, setEmptySubjectDelayedUntil] = useState<string | undefined>();
|
||||
const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false);
|
||||
const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false);
|
||||
const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState<string[]>([]);
|
||||
const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState<string | undefined>();
|
||||
const [placeholderDontAskAgain, setPlaceholderDontAskAgain] = useState(false);
|
||||
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
|
||||
const [scheduleValue, setScheduleValue] = useState('');
|
||||
const [scheduleError, setScheduleError] = useState('');
|
||||
@@ -997,6 +1003,12 @@ export function EmailComposer({
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const placeholderWarningRef = useFocusTrap({
|
||||
isActive: showPlaceholderWarning,
|
||||
onEscape: () => setShowPlaceholderWarning(false),
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const { client: activeClient } = useAuthStore();
|
||||
const composerOwnClientRef = useRef(activeClient);
|
||||
if (onComposerAccount && activeClient) composerOwnClientRef.current = activeClient;
|
||||
@@ -3093,6 +3105,9 @@ export function EmailComposer({
|
||||
const sendCancelledRef = useRef(false);
|
||||
// The user confirmed sending without a subject for this message.
|
||||
const emptySubjectConfirmedRef = useRef(false);
|
||||
// Placeholders the user chose to send as they are. The warning comes back
|
||||
// only for one that is not in here.
|
||||
const confirmedPlaceholdersRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Toasts sit in the bottom corner, right over the composer's Send button.
|
||||
// The attribute lifts them clear while a composer is open (see toast.tsx).
|
||||
@@ -3107,6 +3122,41 @@ export function EmailComposer({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// "Back to editing" from the placeholder warning selects the first
|
||||
// placeholder, so the user can type over it.
|
||||
const selectPlaceholder = (placeholder: string) => {
|
||||
const subjectInput = subjectInputRef.current;
|
||||
const subjectIndex = subjectInput?.value.indexOf(placeholder) ?? -1;
|
||||
if (subjectInput && subjectIndex >= 0) {
|
||||
subjectInput.focus();
|
||||
subjectInput.setSelectionRange(subjectIndex, subjectIndex + placeholder.length);
|
||||
return;
|
||||
}
|
||||
if (plainTextModeRef.current) {
|
||||
const textarea = bodyRef.current;
|
||||
if (!textarea) return;
|
||||
const index = textarea.value.indexOf(placeholder);
|
||||
textarea.focus();
|
||||
if (index >= 0) textarea.setSelectionRange(index, index + placeholder.length);
|
||||
return;
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const range = { from: -1, to: -1 };
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (range.from >= 0) return false;
|
||||
const index = node.isText && node.text ? node.text.indexOf(placeholder) : -1;
|
||||
if (index >= 0) {
|
||||
range.from = pos + index;
|
||||
range.to = range.from + placeholder.length;
|
||||
}
|
||||
});
|
||||
// A placeholder split across formatting has no single text node; then
|
||||
// the caret just goes back into the body.
|
||||
if (range.from >= 0) editor.chain().focus().setTextSelection(range).scrollIntoView().run();
|
||||
else editor.commands.focus();
|
||||
};
|
||||
|
||||
const handleSend = async ({ skipAttachmentCheck = false, skipSubjectCheck = false, delayedUntil }: {
|
||||
skipAttachmentCheck?: boolean;
|
||||
skipSubjectCheck?: boolean;
|
||||
@@ -3176,6 +3226,27 @@ export function EmailComposer({
|
||||
return;
|
||||
}
|
||||
|
||||
// Unfilled placeholders: template fields left as {{name}}, fill-ins like
|
||||
// [TIME] from AI drafts, and markers like TODO. The signature is left
|
||||
// out, or a placeholder in it would stop every message.
|
||||
if (placeholderWarningEnabled) {
|
||||
const authored = plainTextMode
|
||||
? plainTextBodyWithoutSignature(body, signatureIdentity)
|
||||
: stripEmbeddedSignatureHtml(body);
|
||||
const bodyText = extractUserAuthoredText(authored, {
|
||||
plainTextMode,
|
||||
forwardedSeparator: tQuote('forwarded_separator'),
|
||||
});
|
||||
const found = findUnfilledPlaceholders(subject, bodyText);
|
||||
if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) {
|
||||
setPlaceholderWarningMatches(found);
|
||||
setPlaceholderDontAskAgain(false);
|
||||
setPlaceholderWarningDelayedUntil(delayedUntil);
|
||||
setShowPlaceholderWarning(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment reminder check
|
||||
if (!skipAttachmentCheck && attachmentReminderEnabled) {
|
||||
const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
|
||||
@@ -3688,6 +3759,7 @@ export function EmailComposer({
|
||||
showScheduleDialog ||
|
||||
showAttachmentWarning ||
|
||||
showEmptySubjectWarning ||
|
||||
showPlaceholderWarning ||
|
||||
showCloseDialog
|
||||
) return;
|
||||
|
||||
@@ -4824,6 +4896,72 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPlaceholderWarning && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
onClick={() => setShowPlaceholderWarning(false)}
|
||||
>
|
||||
<div
|
||||
ref={placeholderWarningRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="placeholder-warning-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 id="placeholder-warning-title" className="text-lg font-semibold text-foreground">{t('placeholder_warning.title')}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('placeholder_warning.message')}</p>
|
||||
<ul className="mt-3 flex flex-wrap items-center gap-1.5" aria-label={t('placeholder_warning.found')}>
|
||||
{placeholderWarningMatches.slice(0, 5).map((p) => (
|
||||
<li key={p} className="max-w-full truncate rounded bg-muted px-2 py-0.5 font-mono text-xs text-foreground">{p}</li>
|
||||
))}
|
||||
{placeholderWarningMatches.length > 5 && (
|
||||
<li className="text-xs text-muted-foreground">
|
||||
{t('placeholder_warning.more', { count: placeholderWarningMatches.length - 5 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={placeholderDontAskAgain}
|
||||
onChange={(e) => setPlaceholderDontAskAgain(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{t('placeholder_warning.dont_ask_again')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPlaceholderWarning(false);
|
||||
setPlaceholderWarningDelayedUntil(undefined);
|
||||
// The focus trap hands focus back to the Send button as it
|
||||
// tears down, so select on the next tick.
|
||||
const first = placeholderWarningMatches[0];
|
||||
setTimeout(() => { if (first) selectPlaceholder(first); }, 0);
|
||||
}}
|
||||
>
|
||||
{t('placeholder_warning.back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (placeholderDontAskAgain) updateSetting('placeholderWarningEnabled', false);
|
||||
placeholderWarningMatches.forEach((p) => confirmedPlaceholdersRef.current.add(p));
|
||||
setShowPlaceholderWarning(false);
|
||||
handleSend({ skipSubjectCheck: true, delayedUntil: placeholderWarningDelayedUntil });
|
||||
setPlaceholderWarningDelayedUntil(undefined);
|
||||
}}
|
||||
>
|
||||
{t('placeholder_warning.send_anyway')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCloseDialog && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
|
||||
@@ -803,6 +803,15 @@
|
||||
"send_anyway": "Send anyway",
|
||||
"back": "Back to editing"
|
||||
},
|
||||
"placeholder_warning": {
|
||||
"title": "Check placeholders?",
|
||||
"message": "Your message has placeholder text that recipients will see. Do you want to edit it before sending?",
|
||||
"found": "Placeholders found",
|
||||
"more": "and {count} more",
|
||||
"dont_ask_again": "Don't ask again",
|
||||
"send_anyway": "Send anyway",
|
||||
"back": "Back to editing"
|
||||
},
|
||||
"schedule_send": "Schedule send",
|
||||
"schedule_send_description": "Choose when the server should release this message.",
|
||||
"schedule_preset": {
|
||||
|
||||
@@ -682,6 +682,9 @@ interface SettingsState {
|
||||
// Ask for confirmation when sending a message with an empty subject
|
||||
emptySubjectWarningEnabled: boolean;
|
||||
|
||||
// Ask for confirmation when sending a message with unfilled placeholders
|
||||
placeholderWarningEnabled: boolean;
|
||||
|
||||
// "@" in the message body offers the recipients and inserts a first name
|
||||
recipientMentionsEnabled: boolean;
|
||||
// Where an "@" puts a picked contact who isn't a recipient yet; 'off'
|
||||
@@ -975,6 +978,7 @@ const DEFAULT_SETTINGS = {
|
||||
] as string[],
|
||||
|
||||
emptySubjectWarningEnabled: true,
|
||||
placeholderWarningEnabled: true,
|
||||
recipientMentionsEnabled: true,
|
||||
mentionAddsRecipientTo: 'to' as MentionAddsRecipientTo,
|
||||
|
||||
@@ -1275,6 +1279,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||
emptySubjectWarningEnabled: state.emptySubjectWarningEnabled,
|
||||
placeholderWarningEnabled: state.placeholderWarningEnabled,
|
||||
recipientMentionsEnabled: state.recipientMentionsEnabled,
|
||||
mentionAddsRecipientTo: state.mentionAddsRecipientTo,
|
||||
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
||||
|
||||
Reference in New Issue
Block a user