mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Offline sat in the middle of Backup & reset, privacy settings were spread
over Reading, Composing and Message list, and the version card took room
from the page list in the sidebar's footer. The search matched letters
inside words ("lock" found Time format and Send Checks) and knew no other
words for a setting ("offline", "autoreply", "logout" found nothing).
- Offline is a page in the top group: Keep mail on this device, what is
kept, Remove everything from this device, and the Outbox as its own
section. Old `/settings/backup/offline` links and the offline notice
land there.
- Privacy holds remote images and trusted senders, read receipts, sender
pictures from the web, the desktop app's usage info and crash reports,
and a link to the push relay on Notifications. Reading, Composing and
Message list keep a link row; old links to those sections land on
Privacy.
- About is the last page and holds the version card; the footer keeps the
Advanced switch.
- The search matches whole words and word starts (substrings only in
Chinese, Japanese and Thai), and each row can carry synonyms in the
user's language. A page matches by its name and keywords or when one of
its rows has every word. Account gets a Sign out row so "logout" leads
somewhere.
- The index test now also fails when a page draws a section the index
doesn't know, and a new test fails on two indexed rows with the same
name. Tests that rendered remote content and sender pictures on Reading
and Message list render Privacy instead, since that's where the rows are.
269 lines
12 KiB
TypeScript
269 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
import type { MentionAddsRecipientTo, ReplyIdentityMatch, SendDelaySeconds } from '@/stores/settings-store';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { usePolicyStore } from '@/stores/policy-store';
|
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
|
import { TemplateSettings } from './template-settings';
|
|
import { ProofreadingSettings } from './proofreading-settings';
|
|
import { PrivacyLinkRow } from './privacy-settings';
|
|
import { Button } from '@/components/ui/button';
|
|
import { X, ChevronRight } from '@/components/icons';
|
|
|
|
/**
|
|
* The Composing page: the editor, replying, what happens before a send,
|
|
* templates (their own tab before) and pointers to where signatures and
|
|
* read receipts live, since people look for them here.
|
|
*/
|
|
export function ComposingSettings() {
|
|
const t = useTranslations('settings.email_behavior');
|
|
const tPage = useTranslations('settings.composing_page');
|
|
const templatesEnabled = usePolicyStore((s) => s.isFeatureEnabled('templatesEnabled'));
|
|
// The admin panel can lock or hide these three (restriction-keys.ts).
|
|
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
|
const [newKeyword, setNewKeyword] = useState('');
|
|
|
|
const {
|
|
quickReplyEnabled,
|
|
autoSelectReplyIdentity,
|
|
replyIdentityMatch,
|
|
plainTextMode,
|
|
rtlEditingSupport,
|
|
attachmentReminderEnabled,
|
|
attachmentReminderKeywords,
|
|
emptySubjectWarningEnabled,
|
|
placeholderWarningEnabled,
|
|
recipientMentionsEnabled,
|
|
mentionAddsRecipientTo,
|
|
sendDelaySeconds,
|
|
signaturePosition,
|
|
signatureNewMailEnabled,
|
|
signatureReplyForwardEnabled,
|
|
signatureSeparatorEnabled,
|
|
updateSetting,
|
|
} = useSettingsStore();
|
|
const { client } = useAuthStore();
|
|
const delayedSendSupported = client?.hasDelayedSend() ?? false;
|
|
// Catch-all matching only rewrites From, which the admin can switch off.
|
|
const customFromEnabled = usePolicyStore((s) => s.isFeatureEnabled('customFromEnabled'));
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<SettingsSection id="editor" title={tPage('title')} description={tPage('description')}>
|
|
{!isSettingHidden('plainTextMode') && (
|
|
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')} locked={isSettingLocked('plainTextMode')}>
|
|
<ToggleSwitch
|
|
checked={plainTextMode}
|
|
onChange={(checked) => updateSetting('plainTextMode', checked)}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
|
|
<SettingItem label={t('recipient_mentions.label')} description={t('recipient_mentions.description')}>
|
|
<ToggleSwitch
|
|
checked={recipientMentionsEnabled}
|
|
onChange={(checked) => updateSetting('recipientMentionsEnabled', checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
{recipientMentionsEnabled && (
|
|
<SettingItem nested label={t('mention_adds_recipient.label')} description={t('mention_adds_recipient.description')}>
|
|
<Select
|
|
value={mentionAddsRecipientTo}
|
|
onChange={(value) => updateSetting('mentionAddsRecipientTo', value as MentionAddsRecipientTo)}
|
|
options={[
|
|
{ value: 'to', label: t('mention_adds_recipient.to') },
|
|
{ value: 'cc', label: t('mention_adds_recipient.cc') },
|
|
{ value: 'off', label: t('mention_adds_recipient.off') },
|
|
]}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
|
|
<SettingItem advanced label={t('rtl_editing.label')} description={t('rtl_editing.description')}>
|
|
<ToggleSwitch
|
|
checked={rtlEditingSupport}
|
|
onChange={(checked) => updateSetting('rtlEditingSupport', checked)}
|
|
/>
|
|
</SettingItem>
|
|
</SettingsSection>
|
|
|
|
<ProofreadingSettings />
|
|
|
|
<SettingsSection id="replying" title={tPage('sections.replying')}>
|
|
<SettingItem label={t('quick_reply.label')} description={t('quick_reply.description')}>
|
|
<ToggleSwitch
|
|
checked={quickReplyEnabled}
|
|
onChange={(checked) => updateSetting('quickReplyEnabled', checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
{!isSettingHidden('autoSelectReplyIdentity') && (
|
|
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')} locked={isSettingLocked('autoSelectReplyIdentity')}>
|
|
<ToggleSwitch
|
|
checked={autoSelectReplyIdentity}
|
|
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
|
|
{autoSelectReplyIdentity && customFromEnabled && !isSettingHidden('replyIdentityMatch') && (
|
|
<SettingItem nested label={t('reply_identity_match.label')} description={t('reply_identity_match.description')} locked={isSettingLocked('replyIdentityMatch')}>
|
|
<Select
|
|
value={replyIdentityMatch}
|
|
onChange={(value) => updateSetting('replyIdentityMatch', value as ReplyIdentityMatch)}
|
|
options={[
|
|
{ value: 'exact', label: t('reply_identity_match.exact') },
|
|
{ value: 'domain', label: t('reply_identity_match.domain') },
|
|
]}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
|
|
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
|
|
<Select
|
|
value={signaturePosition}
|
|
onChange={(value) => updateSetting('signaturePosition', value as 'above_quote' | 'below_quote')}
|
|
options={[
|
|
{ value: 'above_quote', label: t('signature_position.above_quote') },
|
|
{ value: 'below_quote', label: t('signature_position.below_quote') },
|
|
]}
|
|
/>
|
|
</SettingItem>
|
|
|
|
<SettingItem label={t('signature_new_mail.label')} description={t('signature_new_mail.description')}>
|
|
<ToggleSwitch
|
|
checked={signatureNewMailEnabled}
|
|
onChange={(checked) => updateSetting('signatureNewMailEnabled', checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
<SettingItem label={t('signature_reply_forward.label')} description={t('signature_reply_forward.description')}>
|
|
<ToggleSwitch
|
|
checked={signatureReplyForwardEnabled}
|
|
onChange={(checked) => updateSetting('signatureReplyForwardEnabled', checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
<SettingItem advanced label={t('signature_separator.label')} description={t('signature_separator.description')}>
|
|
<ToggleSwitch
|
|
checked={signatureSeparatorEnabled}
|
|
onChange={(checked) => updateSetting('signatureSeparatorEnabled', checked)}
|
|
/>
|
|
</SettingItem>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection id="sending" title={tPage('sections.sending')}>
|
|
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
|
|
<div className="flex flex-col items-end gap-1">
|
|
<Select
|
|
value={String(sendDelaySeconds)}
|
|
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
|
|
options={[
|
|
{ value: '0', label: t('send_delay.off') },
|
|
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
|
|
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
|
|
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
|
|
]}
|
|
/>
|
|
{sendDelaySeconds > 0 && !delayedSendSupported && (
|
|
<p className="max-w-64 text-end text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
|
|
)}
|
|
</div>
|
|
</SettingItem>
|
|
|
|
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
|
<ToggleSwitch
|
|
checked={attachmentReminderEnabled}
|
|
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
|
|
/>
|
|
</SettingItem>
|
|
{attachmentReminderEnabled && (
|
|
<div className="py-3 border-b border-border space-y-2">
|
|
<div>
|
|
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
|
|
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{attachmentReminderKeywords.map((kw) => (
|
|
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
|
|
{kw}
|
|
<button
|
|
type="button"
|
|
aria-label={t('attachment_reminder.remove')}
|
|
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<form
|
|
className="flex gap-2"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const trimmed = newKeyword.trim().toLowerCase();
|
|
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
|
|
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
|
|
}
|
|
setNewKeyword('');
|
|
}}
|
|
>
|
|
<input
|
|
aria-label={t('attachment_reminder.add_placeholder')}
|
|
type="text"
|
|
value={newKeyword}
|
|
onChange={(e) => setNewKeyword(e.target.value)}
|
|
placeholder={t('attachment_reminder.add_placeholder')}
|
|
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-hidden"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!newKeyword.trim()}
|
|
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{t('attachment_reminder.add')}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
<SettingItem label={t('empty_subject_warning.label')} description={t('empty_subject_warning.description')}>
|
|
<ToggleSwitch
|
|
checked={emptySubjectWarningEnabled}
|
|
onChange={(checked) => updateSetting('emptySubjectWarningEnabled', checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
<SettingItem label={t('placeholder_warning.label')} description={t('placeholder_warning.description')}>
|
|
<ToggleSwitch
|
|
checked={placeholderWarningEnabled}
|
|
onChange={(checked) => updateSetting('placeholderWarningEnabled', checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
<PrivacyLinkRow topic="receipts" />
|
|
</SettingsSection>
|
|
|
|
{templatesEnabled && <TemplateSettings />}
|
|
|
|
<SettingsSection id="signatures" title={t('signatures_link.label')}>
|
|
<SettingItem label={t('signatures_link.label')} description={t('signatures_link.description')}>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: { tab: 'account', section: 'addresses' } }))}
|
|
>
|
|
{t('signatures_link.button')}
|
|
<ChevronRight className="w-4 h-4 ms-1" />
|
|
</Button>
|
|
</SettingItem>
|
|
</SettingsSection>
|
|
</div>
|
|
);
|
|
}
|