From 721608962980918bbf5fecc7b3d1aaa018305a54 Mon Sep 17 00:00:00 2001 From: Max Hao Date: Mon, 26 Oct 2026 11:33:45 -0400 Subject: [PATCH] feat: add quick reply feature with toggle in settings and tests --- .../email/__tests__/viewer-send-failures.test.tsx | 10 ++++++++++ components/email/email-viewer.tsx | 3 ++- components/settings/composing-settings.tsx | 8 ++++++++ locales/en/common.json | 4 ++++ stores/__tests__/settings-store-attachments.test.ts | 8 ++++++++ stores/settings-store.ts | 3 +++ 6 files changed, 35 insertions(+), 1 deletion(-) diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index 2d0f66e30..54328dfa6 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -75,6 +75,7 @@ function makeEmail(overrides: Record = {}): Email { let toastError: ReturnType; beforeEach(() => { + useSettingsStore.setState({ quickReplyEnabled: true }); toastError = vi.spyOn(toast, 'error').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -84,6 +85,15 @@ afterEach(() => { }); describe('viewer send failures', () => { + it('hides quick reply when disabled and restores it when enabled', () => { + useSettingsStore.setState({ quickReplyEnabled: false }); + render(); + + expect(screen.queryByPlaceholderText('quick_reply_placeholder')).not.toBeInTheDocument(); + act(() => useSettingsStore.getState().updateSetting('quickReplyEnabled', true)); + expect(screen.getByPlaceholderText('quick_reply_placeholder')).toBeInTheDocument(); + }); + it('reports a failed quick reply and keeps the text on Stalwart 1.0', async () => { const onQuickReply = vi.fn().mockRejectedValue(new SubmissionQuotaError(QUOTA, true)); render( diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cc179c90f..65ae975ee 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1084,6 +1084,7 @@ export function EmailViewer({ const tContextMenu = useTranslations('context_menu'); const copyLink = useCopyLink(); const tWelcome = useTranslations('welcome'); + const quickReplyEnabled = useSettingsStore((state) => state.quickReplyEnabled); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const messageSpacing = useSettingsStore((state) => state.messageSpacing); const plainTextFont = useSettingsStore((state) => state.plainTextFont); @@ -6384,7 +6385,7 @@ export function EmailViewer({ )} {/* Quick Reply Section - hidden for drafts and while loading a new email */} - {!mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
+ {quickReplyEnabled && !mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
+ + updateSetting('quickReplyEnabled', checked)} + /> + + { useSettingsStore.getState().resetToDefaults(); }); + it('enables quick reply by default and exports the disabled preference', () => { + expect(useSettingsStore.getState().quickReplyEnabled).toBe(true); + useSettingsStore.getState().updateSetting('quickReplyEnabled', false); + expect(JSON.parse(useSettingsStore.getState().exportSettings()).quickReplyEnabled).toBe(false); + useSettingsStore.getState().resetToDefaults(); + expect(useSettingsStore.getState().quickReplyEnabled).toBe(true); + }); + it('defaults to preview when settings are reset', () => { expect(useSettingsStore.getState().mailAttachmentAction).toBe('preview'); }); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 217931e2f..c1fafd300 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -528,6 +528,7 @@ interface SettingsState { // Composer autoSaveDraftInterval: number; // milliseconds sendConfirmation: boolean; + quickReplyEnabled: boolean; defaultReplyMode: ReplyMode; autoSelectReplyIdentity: boolean; replyIdentityMatch: ReplyIdentityMatch; // With autoSelectReplyIdentity on: 'exact' = configured identities only, 'domain' = also same-domain catch-all addresses (rewrites From) #1000 @@ -836,6 +837,7 @@ const DEFAULT_SETTINGS = { // Composer autoSaveDraftInterval: 60000, // 1 minute sendConfirmation: false, + quickReplyEnabled: true, defaultReplyMode: 'reply' as ReplyMode, autoSelectReplyIdentity: false, replyIdentityMatch: 'domain' as ReplyIdentityMatch, @@ -1202,6 +1204,7 @@ export const useSettingsStore = create()( trustedSenders: state.trustedSenders, autoSaveDraftInterval: state.autoSaveDraftInterval, sendConfirmation: state.sendConfirmation, + quickReplyEnabled: state.quickReplyEnabled, defaultReplyMode: state.defaultReplyMode, autoSelectReplyIdentity: state.autoSelectReplyIdentity, replyIdentityMatch: state.replyIdentityMatch,