mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +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.
414 lines
18 KiB
TypeScript
414 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
import { useEffectiveMailLayout } from '@/hooks/use-effective-mail-layout';
|
|
import { useMediaQuery } from '@/hooks/use-media-query';
|
|
import type { ArchiveMode, HoverAction, MvInboxType, SwipeAction } from '@/stores/settings-store';
|
|
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useEmailStore } from '@/stores/email-store';
|
|
import { cn } from '@/lib/utils';
|
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
|
import { MessageListOrderSettings } from './message-list-order-settings';
|
|
import { PrivacyLinkRow } from './privacy-settings';
|
|
import { AlertTriangle, FolderSync, Loader2 } from '@/components/icons';
|
|
import { usePolicyStore } from '@/stores/policy-store';
|
|
import { allowEmailMove, createMailboxWithHooks, emitEmailMove } from '@/lib/plugin-mail-hooks';
|
|
import type { Mailbox } from '@/lib/jmap/types';
|
|
import type { SearchDefaultScope } from '@/lib/search-default-scope';
|
|
|
|
/**
|
|
* The Message list page: what the list shows, what one hover or swipe does,
|
|
* and what Delete and Archive mean. These rows used to be spread over
|
|
* Reading, Layout and Appearance. Sender pictures from the web are on
|
|
* Privacy, with a link here.
|
|
*/
|
|
export function MessageListSettings() {
|
|
const t = useTranslations('settings.email_behavior');
|
|
const tPage = useTranslations('settings.message_list');
|
|
const tAppearance = useTranslations('settings.appearance');
|
|
const [isReorganizing, setIsReorganizing] = useState(false);
|
|
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
|
|
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
|
const archiveActionEnabled = isFeatureEnabled('archiveActionEnabled');
|
|
// Swipes exist on touch screens only; a mouse user never sees these rows.
|
|
const touchDevice = useMediaQuery('(pointer: coarse)');
|
|
// With the Archive action off, a stored 'archive' swipe does nothing (the row
|
|
// has no archive handler), so the select shows it as "none".
|
|
const swipeShown = (action: SwipeAction) => (archiveActionEnabled || action !== 'archive' ? action : 'none');
|
|
const swipeOptions = [
|
|
{ value: 'none', label: t('swipe_actions.none') },
|
|
...(archiveActionEnabled ? [{ value: 'archive', label: t('swipe_actions.archive') }] : []),
|
|
{ value: 'delete', label: t('swipe_actions.delete') },
|
|
{ value: 'markRead', label: t('swipe_actions.mark_read') },
|
|
{ value: 'star', label: t('swipe_actions.star') },
|
|
{ value: 'spam', label: t('swipe_actions.spam') },
|
|
];
|
|
|
|
const {
|
|
deleteAction,
|
|
permanentlyDeleteJunk,
|
|
confirmBulkDelete,
|
|
swipeRightAction,
|
|
swipeLeftAction,
|
|
clearSearchOnFolderChange,
|
|
searchDefaultScope,
|
|
showPreview,
|
|
showVerificationCodes,
|
|
disableThreading,
|
|
emailsPerPage,
|
|
archiveMode,
|
|
hoverActions,
|
|
hoverActionsMode,
|
|
hoverActionsCorner,
|
|
listColumns,
|
|
mailLayout,
|
|
interfaceLayout,
|
|
mvInboxType,
|
|
tintListRowsByTag,
|
|
updateSetting,
|
|
} = useSettingsStore();
|
|
|
|
const isFocusedLayout = useEffectiveMailLayout() === 'focus';
|
|
const mountainView = interfaceLayout === 'mountain-view';
|
|
|
|
const handleReorganizeArchive = async () => {
|
|
const { client } = useAuthStore.getState();
|
|
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
|
if (!client) return;
|
|
|
|
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
|
|
if (!archiveMailbox) return;
|
|
|
|
setIsReorganizing(true);
|
|
setReorganizeResult(null);
|
|
|
|
try {
|
|
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
|
const emails = await client.getEmailsInMailbox(archiveId);
|
|
|
|
// One group per destination ("2024" or "2024/03"): plugins are asked
|
|
// once per folder and move, and can refuse either; the groups they
|
|
// refuse stay where they are.
|
|
const groups = new Map<string, { year: string; month: string; ids: string[] }>();
|
|
for (const email of emails) {
|
|
const emailDate = new Date(email.receivedAt);
|
|
const year = emailDate.getFullYear().toString();
|
|
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
|
const key = archiveMode === 'year' ? year : `${year}/${month}`;
|
|
const group = groups.get(key) ?? { year, month, ids: [] };
|
|
group.ids.push(email.id);
|
|
groups.set(key, group);
|
|
}
|
|
|
|
// An existing folder, or a new one; null when a plugin refused it.
|
|
const folder = async (name: string, parentId: string): Promise<Mailbox | null> => {
|
|
const existing = useEmailStore.getState().mailboxes.find(m => m.name === name && m.parentId === parentId);
|
|
if (existing) return existing;
|
|
const created = await createMailboxWithHooks(client, name, parentId);
|
|
if (created) await fetchMailboxes(client);
|
|
return useEmailStore.getState().mailboxes.find(m => m.name === name && m.parentId === parentId) ?? created;
|
|
};
|
|
|
|
let movedCount = 0;
|
|
for (const { year, month, ids } of groups.values()) {
|
|
const yearMailbox = await folder(year, archiveId);
|
|
if (!yearMailbox) continue;
|
|
const destination = archiveMode === 'year'
|
|
? yearMailbox
|
|
: await folder(month, yearMailbox.originalId || yearMailbox.id);
|
|
if (!destination) continue;
|
|
|
|
const known = useEmailStore.getState().mailboxes;
|
|
const move = { emailIds: ids, fromMailboxId: archiveMailbox.id, toMailboxId: destination.id };
|
|
if (!(await allowEmailMove(move, known))) continue;
|
|
await client.batchMoveEmails(ids, destination.originalId || destination.id);
|
|
emitEmailMove(move, known);
|
|
movedCount += ids.length;
|
|
}
|
|
|
|
setReorganizeResult(t('archive_mode.reorganize_success', { count: movedCount }));
|
|
} catch (error) {
|
|
console.error('Failed to reorganize archive:', error);
|
|
setReorganizeResult(t('archive_mode.reorganize_error'));
|
|
} finally {
|
|
setIsReorganizing(false);
|
|
}
|
|
};
|
|
|
|
const segment = (active: boolean) => cn(
|
|
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150',
|
|
active ? 'bg-primary text-primary-foreground font-medium' : 'bg-muted hover:bg-accent text-foreground'
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<SettingsSection id="display" title={tPage('title')} description={tPage('description')}>
|
|
{/* Stored as "disable threading"; shown as the positive choice. */}
|
|
<SettingItem label={t('group_conversations.label')} description={t('group_conversations.description')}>
|
|
<ToggleSwitch
|
|
checked={!disableThreading}
|
|
onChange={(checked) => updateSetting('disableThreading', !checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
{!isSettingHidden('showPreview') && (
|
|
<SettingItem
|
|
label={t('show_preview.label')}
|
|
description={isFocusedLayout ? t('show_preview.focus_description') : t('show_preview.description')}
|
|
locked={isSettingLocked('showPreview')}
|
|
>
|
|
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
|
</SettingItem>
|
|
)}
|
|
|
|
{/* Only the layouts with one-line rows can draw them as a table. */}
|
|
{!isSettingHidden('listColumns') && !mountainView && mailLayout !== 'split' && (
|
|
<SettingItem label={t('list_columns.label')} description={t('list_columns.description')} locked={isSettingLocked('listColumns')}>
|
|
<ToggleSwitch
|
|
checked={listColumns}
|
|
onChange={(checked) => updateSetting('listColumns', checked)}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
|
|
{/* Gmail's inbox types; "Unread first" puts the newest unread mail in a section above the rest. */}
|
|
{mountainView && (
|
|
<SettingItem label={t('inbox_type.label')} description={t('inbox_type.description')}>
|
|
<Select
|
|
value={mvInboxType}
|
|
onChange={(value) => {
|
|
updateSetting('mvInboxType', value as MvInboxType);
|
|
const client = useAuthStore.getState().client;
|
|
const store = useEmailStore.getState();
|
|
if (client && store.selectedMailbox) void store.fetchEmails(client, store.selectedMailbox, { background: true });
|
|
}}
|
|
options={[
|
|
{ value: 'default', label: t('inbox_type.default') },
|
|
{ value: 'unread-first', label: t('inbox_type.unread_first') },
|
|
]}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
|
|
<MessageListOrderSettings />
|
|
|
|
<SettingItem label={tAppearance('tint_list_rows.label')} description={tAppearance('tint_list_rows.description')}>
|
|
<ToggleSwitch
|
|
checked={tintListRowsByTag}
|
|
onChange={(checked) => updateSetting('tintListRowsByTag', checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
<SettingItem label={t('show_verification_codes.label')} description={t('show_verification_codes.description')}>
|
|
<ToggleSwitch
|
|
checked={showVerificationCodes}
|
|
onChange={(checked) => updateSetting('showVerificationCodes', checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
<PrivacyLinkRow topic="senders" />
|
|
</SettingsSection>
|
|
|
|
{(isFeatureEnabled('hoverActionsConfigEnabled') || touchDevice) && (
|
|
<SettingsSection id="actions" title={tPage('sections.actions')}>
|
|
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
|
<div data-search-label={t('hover_actions.label')} className="py-3 border-b border-border last:border-0 space-y-3">
|
|
<div>
|
|
<label className="text-sm font-medium text-foreground">{t('hover_actions.label')}</label>
|
|
<p className="text-xs text-muted-foreground mt-1">{t('hover_actions.description')}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{ALL_HOVER_ACTIONS.filter((action) => archiveActionEnabled || action.id !== 'archive').map((action) => {
|
|
const isEnabled = hoverActions.includes(action.id);
|
|
return (
|
|
<button
|
|
key={action.id}
|
|
type="button"
|
|
onClick={() => {
|
|
const newActions = isEnabled
|
|
? hoverActions.filter((a: HoverAction) => a !== action.id)
|
|
: [...hoverActions, action.id];
|
|
updateSetting('hoverActions', newActions);
|
|
}}
|
|
className={segment(isEnabled)}
|
|
>
|
|
{t(`hover_actions.${action.labelKey}`)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="pt-2 space-y-2">
|
|
<label className="text-xs font-medium text-foreground">{t('hover_actions.mode_label')}</label>
|
|
<div className="flex gap-2">
|
|
{(['inline', 'floating'] as const).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
onClick={() => updateSetting('hoverActionsMode', mode)}
|
|
className={segment(hoverActionsMode === mode)}
|
|
>
|
|
{t(`hover_actions.mode_${mode}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{hoverActionsMode === 'floating' && (
|
|
<div className="pt-1 space-y-2">
|
|
<label className="text-xs font-medium text-foreground">{t('hover_actions.corner_label')}</label>
|
|
<div className="grid grid-cols-2 gap-2 w-48">
|
|
{(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).map((corner) => (
|
|
<button
|
|
key={corner}
|
|
type="button"
|
|
onClick={() => updateSetting('hoverActionsCorner', corner)}
|
|
className={cn(segment(hoverActionsCorner === corner), 'px-2 text-center')}
|
|
>
|
|
{t(`hover_actions.corner_${corner}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{touchDevice && (
|
|
<SettingItem label={t('swipe_right_action.label')} description={t('swipe_right_action.description')}>
|
|
<Select
|
|
value={swipeShown(swipeRightAction)}
|
|
onChange={(value) => updateSetting('swipeRightAction', value as typeof swipeRightAction)}
|
|
options={swipeOptions}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
|
|
{touchDevice && (
|
|
<SettingItem label={t('swipe_left_action.label')} description={t('swipe_left_action.description')}>
|
|
<Select
|
|
value={swipeShown(swipeLeftAction)}
|
|
onChange={(value) => updateSetting('swipeLeftAction', value as typeof swipeLeftAction)}
|
|
options={swipeOptions}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
</SettingsSection>
|
|
)}
|
|
|
|
<SettingsSection id="delete" title={tPage('sections.delete')}>
|
|
{!isSettingHidden('deleteAction') && (
|
|
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
|
|
<div className="flex flex-col gap-2">
|
|
<Select
|
|
value={deleteAction}
|
|
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')}
|
|
options={[
|
|
{ value: 'trash', label: t('delete_action.trash') },
|
|
{ value: 'trash-and-read', label: t('delete_action.trash_and_read') },
|
|
{ value: 'permanent', label: t('delete_action.permanent') },
|
|
]}
|
|
/>
|
|
{deleteAction === 'permanent' && (
|
|
<div className="flex items-start gap-2 p-2 rounded-md bg-destructive/10 text-destructive text-xs">
|
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
|
<span>{t('delete_action.warning')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SettingItem>
|
|
)}
|
|
|
|
{archiveActionEnabled && (
|
|
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
|
|
<div className="flex flex-col gap-2">
|
|
<Select
|
|
value={archiveMode}
|
|
onChange={(value) => updateSetting('archiveMode', value as ArchiveMode)}
|
|
options={[
|
|
{ value: 'single', label: t('archive_mode.single') },
|
|
{ value: 'year', label: t('archive_mode.year') },
|
|
{ value: 'month', label: t('archive_mode.month') },
|
|
]}
|
|
/>
|
|
{archiveMode !== 'single' && (
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
onClick={handleReorganizeArchive}
|
|
disabled={isReorganizing}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors text-sm disabled:opacity-50"
|
|
>
|
|
{isReorganizing ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<FolderSync className="w-4 h-4" />
|
|
)}
|
|
<span>{t('archive_mode.reorganize')}</span>
|
|
</button>
|
|
{reorganizeResult && (
|
|
<p className="text-xs text-muted-foreground">{reorganizeResult}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SettingItem>
|
|
)}
|
|
|
|
<SettingItem label={t('confirm_bulk_delete.label')} description={t('confirm_bulk_delete.description')}>
|
|
<ToggleSwitch
|
|
checked={confirmBulkDelete}
|
|
onChange={(checked) => updateSetting('confirmBulkDelete', checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')} advanced>
|
|
<ToggleSwitch
|
|
checked={permanentlyDeleteJunk}
|
|
onChange={(checked) => updateSetting('permanentlyDeleteJunk', checked)}
|
|
/>
|
|
</SettingItem>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection id="loading" title={tPage('sections.loading')} advanced>
|
|
{!isSettingHidden('emailsPerPage') && (
|
|
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')} advanced>
|
|
<Select
|
|
value={emailsPerPage.toString()}
|
|
onChange={(value) => updateSetting('emailsPerPage', parseInt(value))}
|
|
options={[
|
|
{ value: '10', label: t('emails_per_page.10') },
|
|
{ value: '25', label: t('emails_per_page.25') },
|
|
{ value: '50', label: t('emails_per_page.50') },
|
|
{ value: '100', label: t('emails_per_page.100') },
|
|
]}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
|
|
{!isSettingHidden('searchDefaultScope') && (
|
|
<SettingItem label={t('search_default_scope.label')} description={t('search_default_scope.description')} locked={isSettingLocked('searchDefaultScope')} advanced>
|
|
<Select
|
|
value={searchDefaultScope}
|
|
onChange={(value) => updateSetting('searchDefaultScope', value as SearchDefaultScope)}
|
|
options={[
|
|
{ value: 'all', label: t('search_default_scope.all') },
|
|
{ value: 'folder', label: t('search_default_scope.folder') },
|
|
]}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
|
|
<SettingItem label={t('clear_search_on_folder_change.label')} description={t('clear_search_on_folder_change.description')} advanced>
|
|
<ToggleSwitch
|
|
checked={clearSearchOnFolderChange}
|
|
onChange={(checked) => updateSetting('clearSearchOnFolderChange', checked)}
|
|
/>
|
|
</SettingItem>
|
|
</SettingsSection>
|
|
</div>
|
|
);
|
|
}
|