feat: add notification settings with sound picker and preview

This commit is contained in:
Linus Rath
2026-03-22 01:55:21 +01:00
parent d915e5fb64
commit 8eff9fdfab
21 changed files with 590 additions and 66 deletions
+1 -1
View File
@@ -148,7 +148,7 @@ export default function LoginPage() {
if (!serverUrl) return; if (!serverUrl) return;
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) && if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) &&
inputRef.current && !inputRef.current.contains(event.target as Node)) { inputRef.current && !inputRef.current.contains(event.target as Node)) {
setShowSuggestions(false); setShowSuggestions(false);
} }
if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) { if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) {
+4 -1
View File
@@ -407,7 +407,10 @@ export default function Home() {
// Handle new email notifications - play sound // Handle new email notifications - play sound
useEffect(() => { useEffect(() => {
if (newEmailNotification) { if (newEmailNotification) {
playNotificationSound(); const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
if (emailNotificationsEnabled && emailNotificationSound) {
playNotificationSound(notificationSoundChoice);
}
debug.log('New email received:', newEmailNotification.subject); debug.log('New email received:', newEmailNotification.subject);
clearNewEmailNotification(); clearNewEmailNotification();
} }
+6 -1
View File
@@ -24,6 +24,7 @@ import {
BookUser, BookUser,
KeyRound, KeyRound,
PanelLeftClose, PanelLeftClose,
Bell,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -44,6 +45,7 @@ import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings'; import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { NotificationSettings } from '@/components/settings/notification-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query'; import { useIsDesktop } from '@/hooks/use-media-query';
@@ -55,7 +57,7 @@ import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced'; type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef { interface TabDef {
@@ -68,6 +70,7 @@ interface TabDef {
const tabIcons: Record<Tab, LucideIcon> = { const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette, appearance: Palette,
email: Mail, email: Mail,
notifications: Bell,
account: User, account: User,
security: Shield, security: Shield,
identities: UserPen, identities: UserPen,
@@ -138,6 +141,7 @@ export default function SettingsPage() {
const tabs: TabDef[] = [ const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' }, { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' }, { id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' }, { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []), ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' }, { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
@@ -177,6 +181,7 @@ export default function SettingsPage() {
<> <>
{activeTab === 'appearance' && <AppearanceSettings />} {activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />} {activeTab === 'email' && <EmailSettings />}
{activeTab === 'notifications' && <NotificationSettings />}
{activeTab === 'account' && <AccountSettings />} {activeTab === 'account' && <AccountSettings />}
{activeTab === 'security' && <AccountSecuritySettings />} {activeTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'identities' && <IdentitySettings />} {activeTab === 'identities' && <IdentitySettings />}
-34
View File
@@ -16,9 +16,6 @@ export function CalendarSettings() {
firstDayOfWeek, firstDayOfWeek,
showTimeInMonthView, showTimeInMonthView,
showWeekNumbers, showWeekNumbers,
calendarNotificationsEnabled,
calendarNotificationSound,
calendarInvitationParsingEnabled,
enableCalendarTasks, enableCalendarTasks,
showTasksOnCalendar, showTasksOnCalendar,
updateSetting, updateSetting,
@@ -103,37 +100,6 @@ export function CalendarSettings() {
</SettingItem> </SettingItem>
)} )}
<SettingItem
label={t('notifications_enabled')}
description={t('notifications_enabled_desc')}
>
<ToggleSwitch
checked={calendarNotificationsEnabled}
onChange={(checked) => updateSetting('calendarNotificationsEnabled', checked)}
/>
</SettingItem>
<SettingItem
label={t('notification_sound')}
description={t('notification_sound_desc')}
>
<ToggleSwitch
checked={calendarNotificationSound}
onChange={(checked) => updateSetting('calendarNotificationSound', checked)}
disabled={!calendarNotificationsEnabled}
/>
</SettingItem>
<SettingItem
label={t('invitation_parsing')}
description={t('invitation_parsing_desc')}
>
<ToggleSwitch
checked={calendarInvitationParsingEnabled}
onChange={(checked) => updateSetting('calendarInvitationParsingEnabled', checked)}
/>
</SettingItem>
</SettingsSection> </SettingsSection>
); );
} }
@@ -0,0 +1,115 @@
"use client";
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
import { Volume2 } from 'lucide-react';
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
const {
emailNotificationsEnabled,
emailNotificationSound,
notificationSoundChoice,
calendarNotificationsEnabled,
calendarNotificationSound,
calendarInvitationParsingEnabled,
updateSetting,
} = useSettingsStore();
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
value: s.id,
label: t(`sounds.${s.id}`),
}));
return (
<div className="space-y-8">
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
<SettingItem
label={t('sound_selection.choose')}
description={t('sound_selection.choose_desc')}
>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => playNotificationSound(notificationSoundChoice)}
title={t('test_sound')}
>
<Volume2 className="w-4 h-4" />
</Button>
<Select
value={notificationSoundChoice}
onChange={(value) => {
const choice = value as NotificationSoundChoice;
updateSetting('notificationSoundChoice', choice);
playNotificationSound(choice);
}}
options={soundOptions}
/>
</div>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('email.title')} description={t('email.description')}>
<SettingItem
label={t('email.enabled')}
description={t('email.enabled_desc')}
>
<ToggleSwitch
checked={emailNotificationsEnabled}
onChange={(checked) => updateSetting('emailNotificationsEnabled', checked)}
/>
</SettingItem>
<SettingItem
label={t('email.sound')}
description={t('email.sound_desc')}
>
<ToggleSwitch
checked={emailNotificationSound}
onChange={(checked) => updateSetting('emailNotificationSound', checked)}
disabled={!emailNotificationsEnabled}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('calendar.title')} description={t('calendar.description')}>
<SettingItem
label={t('calendar.enabled')}
description={t('calendar.enabled_desc')}
>
<ToggleSwitch
checked={calendarNotificationsEnabled}
onChange={(checked) => updateSetting('calendarNotificationsEnabled', checked)}
/>
</SettingItem>
<SettingItem
label={t('calendar.sound')}
description={t('calendar.sound_desc')}
>
<ToggleSwitch
checked={calendarNotificationSound}
onChange={(checked) => updateSetting('calendarNotificationSound', checked)}
disabled={!calendarNotificationsEnabled}
/>
</SettingItem>
<SettingItem
label={t('calendar.invitation_parsing')}
description={t('calendar.invitation_parsing_desc')}
>
<ToggleSwitch
checked={calendarInvitationParsingEnabled}
onChange={(checked) => updateSetting('calendarInvitationParsingEnabled', checked)}
/>
</SettingItem>
</SettingsSection>
</div>
);
}
+4 -4
View File
@@ -19,7 +19,7 @@ const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5;
export function useCalendarAlerts() { export function useCalendarAlerts() {
const { isAuthenticated, client } = useAuthStore(); const { isAuthenticated, client } = useAuthStore();
const { events, calendars, supportsCalendar } = useCalendarStore(); const { events, calendars, supportsCalendar } = useCalendarStore();
const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks } = useSettingsStore(); const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore();
const { tasks: storeTasks } = useTaskStore(); const { tasks: storeTasks } = useTaskStore();
const { acknowledgedAlerts, acknowledgeAlert, cleanupStaleAlerts } = useCalendarNotificationStore(); const { acknowledgedAlerts, acknowledgeAlert, cleanupStaleAlerts } = useCalendarNotificationStore();
const addToast = useToastStore((s) => s.addToast); const addToast = useToastStore((s) => s.addToast);
@@ -47,7 +47,7 @@ export function useCalendarAlerts() {
acknowledgeAlert(key, alert.fireTimeMs); acknowledgeAlert(key, alert.fireTimeMs);
if (calendarNotificationSound) { if (calendarNotificationSound) {
playNotificationSound(); playNotificationSound(notificationSoundChoice);
} }
const diffMs = new Date(alert.event.utcStart || alert.event.start).getTime() - now; const diffMs = new Date(alert.event.utcStart || alert.event.start).getTime() - now;
@@ -83,7 +83,7 @@ export function useCalendarAlerts() {
acknowledgeAlert(key, taskAlert.fireTimeMs); acknowledgeAlert(key, taskAlert.fireTimeMs);
if (calendarNotificationSound) { if (calendarNotificationSound) {
playNotificationSound(); playNotificationSound(notificationSoundChoice);
} }
const taskMsg = taskAlert.calendarName const taskMsg = taskAlert.calendarName
@@ -105,7 +105,7 @@ export function useCalendarAlerts() {
// Silently ignore alert evaluation errors // Silently ignore alert evaluation errors
} }
}, [ }, [
calendarNotificationsEnabled, calendarNotificationSound, calendarNotificationsEnabled, calendarNotificationSound, notificationSoundChoice,
isAuthenticated, events, calendars, acknowledgedAlerts, isAuthenticated, events, calendars, acknowledgedAlerts,
acknowledgeAlert, addToast, t, locale, acknowledgeAlert, addToast, t, locale,
]); ]);
+114 -3
View File
@@ -3479,6 +3479,8 @@ export class JMAPClient implements IJMAPClient {
private pollingInterval: NodeJS.Timeout | null = null; private pollingInterval: NodeJS.Timeout | null = null;
private pollingStates: { [key: string]: string } = {}; private pollingStates: { [key: string]: string } = {};
private sseAbortController: AbortController | null = null;
private sseReconnectTimeout: NodeJS.Timeout | null = null;
private static readonly STATE_TYPE_MAP: Record<string, string> = { private static readonly STATE_TYPE_MAP: Record<string, string> = {
'Mailbox/get': 'Mailbox', 'Mailbox/get': 'Mailbox',
@@ -3488,13 +3490,114 @@ export class JMAPClient implements IJMAPClient {
'SieveScript/get': 'SieveScript', 'SieveScript/get': 'SieveScript',
}; };
// Polling-based push since EventSource cannot send Authorization headers private static readonly POLLING_INTERVAL = 3_000;
private static readonly SSE_RECONNECT_DELAY = 3_000;
setupPushNotifications(): boolean { setupPushNotifications(): boolean {
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl) {
this.connectSSE(eventSourceUrl);
} else {
this.startPollingFallback();
}
return true;
}
private connectSSE(templateUrl: string): void {
const url = templateUrl
.replace('{types}', '*')
.replace('{closeafter}', 'no')
.replace('{ping}', '30');
this.sseAbortController = new AbortController();
fetch(url, {
headers: { 'Authorization': this.authHeader, 'Accept': 'text/event-stream' },
signal: this.sseAbortController.signal,
}).then(response => {
if (!response.ok || !response.body) {
this.fallbackToPolling();
return;
}
this.readSSEStream(response.body);
}).catch(() => {
this.fallbackToPolling();
});
}
private async readSSEStream(body: ReadableStream<Uint8Array>): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
this.processSSEEvent(part);
}
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
}
// Stream ended — reconnect unless we were intentionally closed
if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
this.scheduleSSEReconnect();
}
}
private processSSEEvent(raw: string): void {
let eventType = 'message';
let dataLines: string[] = [];
for (const line of raw.split('\n')) {
if (line.startsWith('event:')) {
eventType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
if (eventType === 'state' && dataLines.length > 0) {
try {
const change = JSON.parse(dataLines.join('\n')) as StateChange;
this.stateChangeCallback?.(change);
} catch {
// Malformed SSE data — ignore
}
}
}
private scheduleSSEReconnect(): void {
const eventSourceUrl = this.getEventSourceUrl();
if (!eventSourceUrl) {
this.fallbackToPolling();
return;
}
this.sseReconnectTimeout = setTimeout(() => {
this.connectSSE(eventSourceUrl);
}, JMAPClient.SSE_RECONNECT_DELAY);
}
private fallbackToPolling(): void {
this.sseAbortController = null;
if (!this.pollingInterval) {
this.startPollingFallback();
}
}
private startPollingFallback(): void {
this.fetchCurrentStates(); this.fetchCurrentStates();
this.pollingInterval = setInterval(() => { this.pollingInterval = setInterval(() => {
this.checkForStateChanges(); this.checkForStateChanges();
}, 15_000); }, JMAPClient.POLLING_INTERVAL);
return true;
} }
private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } { private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } {
@@ -3588,6 +3691,14 @@ export class JMAPClient implements IJMAPClient {
clearInterval(this.pollingInterval); clearInterval(this.pollingInterval);
this.pollingInterval = null; this.pollingInterval = null;
} }
if (this.sseAbortController) {
this.sseAbortController.abort();
this.sseAbortController = null;
}
if (this.sseReconnectTimeout) {
clearTimeout(this.sseReconnectTimeout);
this.sseReconnectTimeout = null;
}
if (this.eventSource) { if (this.eventSource) {
this.eventSource.close(); this.eventSource.close();
this.eventSource = null; this.eventSource = null;
+44 -14
View File
@@ -1,21 +1,51 @@
import { debug } from '@/lib/debug'; import { debug } from '@/lib/debug';
export function playNotificationSound() { export type NotificationSoundChoice = 'default' | 'cheerful' | 'involved' | 'swift' | 'relax';
export const NOTIFICATION_SOUNDS: { id: NotificationSoundChoice; file?: string }[] = [
{ id: 'default' },
{ id: 'cheerful', file: '/notification/cheerful-527.mp3' },
{ id: 'involved', file: '/notification/involved-notification.mp3' },
{ id: 'swift', file: '/notification/notification-tone-swift-gesture.mp3' },
{ id: 'relax', file: '/notification/relax-message-tone.mp3' },
];
function playBeep() {
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.value = 0.1;
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.15);
oscillator.onended = () => audioContext.close();
}
function playFile(file: string) {
const audio = new Audio(file);
audio.volume = 0.3;
audio.play().catch((e) => {
debug.log('Could not play audio file, falling back to beep:', e);
playBeep();
});
}
export function playNotificationSound(sound?: NotificationSoundChoice) {
try { try {
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); const choice = sound ?? 'default';
const oscillator = audioContext.createOscillator(); const entry = NOTIFICATION_SOUNDS.find((s) => s.id === choice);
const gainNode = audioContext.createGain();
oscillator.connect(gainNode); if (entry?.file) {
gainNode.connect(audioContext.destination); playFile(entry.file);
} else {
oscillator.frequency.value = 800; playBeep();
oscillator.type = 'sine'; }
gainNode.gain.value = 0.1;
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.15);
oscillator.onended = () => audioContext.close();
} catch (e) { } catch (e) {
debug.log('Could not play notification sound:', e); debug.log('Could not play notification sound:', e);
} }
+36 -1
View File
@@ -624,7 +624,8 @@
"encryption": "Verschlüsselung", "encryption": "Verschlüsselung",
"files": "Dateien", "files": "Dateien",
"contacts": "Contacts", "contacts": "Contacts",
"sidebar_apps": "Sidebar-Apps" "sidebar_apps": "Sidebar-Apps",
"notifications": "Benachrichtigungen"
}, },
"tab_groups": { "tab_groups": {
"general": "Allgemein", "general": "Allgemein",
@@ -696,6 +697,40 @@
"migrating": "Schlüsselwort bei bestehenden E-Mails aktualisieren…", "migrating": "Schlüsselwort bei bestehenden E-Mails aktualisieren…",
"migration_error": "Schlüsselwort konnte bei bestehenden E-Mails nicht aktualisiert werden" "migration_error": "Schlüsselwort konnte bei bestehenden E-Mails nicht aktualisiert werden"
}, },
"notifications": {
"test_sound": "Benachrichtigungston testen",
"sounds": {
"default": "Standard (Piepton)",
"cheerful": "Fröhlich",
"involved": "Aufwendig",
"swift": "Schnelle Geste",
"relax": "Entspannt"
},
"sound_selection": {
"title": "Benachrichtigungston",
"description": "Wählen Sie den Ton für Benachrichtigungen",
"choose": "Ton",
"choose_desc": "Wählen Sie einen Benachrichtigungston und klicken Sie auf das Lautsprechersymbol zur Vorschau"
},
"email": {
"title": "E-Mail-Benachrichtigungen",
"description": "Benachrichtigungen für eingehende E-Mails konfigurieren",
"enabled": "E-Mail-Benachrichtigungen",
"enabled_desc": "Benachrichtigungen anzeigen, wenn neue E-Mails eintreffen",
"sound": "Benachrichtigungston",
"sound_desc": "Einen Ton abspielen, wenn neue E-Mails eintreffen"
},
"calendar": {
"title": "Kalender-Benachrichtigungen",
"description": "Benachrichtigungen für Kalendertermine konfigurieren",
"enabled": "Terminbenachrichtigungen",
"enabled_desc": "Erinnerungen für bevorstehende Kalendertermine anzeigen",
"sound": "Benachrichtigungston",
"sound_desc": "Einen Ton für Kalendererinnerungen abspielen",
"invitation_parsing": "E-Mail-Einladungen erkennen",
"invitation_parsing_desc": "Kalendereinladungen in E-Mail-Anhängen erkennen und Kalenderaktionen anzeigen"
}
},
"language_region": { "language_region": {
"title": "Sprache & Region", "title": "Sprache & Region",
"description": "Konfigurieren Sie Sprach- und Regionaleinstellungen", "description": "Konfigurieren Sie Sprach- und Regionaleinstellungen",
+36 -1
View File
@@ -624,7 +624,8 @@
"files": "Files", "files": "Files",
"contacts": "Contacts", "contacts": "Contacts",
"encryption": "Encryption", "encryption": "Encryption",
"sidebar_apps": "Sidebar Apps" "sidebar_apps": "Sidebar Apps",
"notifications": "Notifications"
}, },
"tab_groups": { "tab_groups": {
"general": "General", "general": "General",
@@ -696,6 +697,40 @@
"migrating": "Updating keyword on existing emails…", "migrating": "Updating keyword on existing emails…",
"migration_error": "Failed to update keyword on existing emails" "migration_error": "Failed to update keyword on existing emails"
}, },
"notifications": {
"test_sound": "Test notification sound",
"sounds": {
"default": "Default (Beep)",
"cheerful": "Cheerful",
"involved": "Involved",
"swift": "Swift Gesture",
"relax": "Relax"
},
"sound_selection": {
"title": "Notification Sound",
"description": "Choose which sound to play for notifications",
"choose": "Sound",
"choose_desc": "Select a notification tone and click the speaker icon to preview it"
},
"email": {
"title": "Email Notifications",
"description": "Configure notifications for incoming emails",
"enabled": "Email notifications",
"enabled_desc": "Show notifications when new emails arrive",
"sound": "Notification sound",
"sound_desc": "Play an audio alert when new emails arrive"
},
"calendar": {
"title": "Calendar Notifications",
"description": "Configure notifications for calendar events",
"enabled": "Event notifications",
"enabled_desc": "Show alerts for upcoming calendar events",
"sound": "Notification sound",
"sound_desc": "Play an audio alert for calendar reminders",
"invitation_parsing": "Parse email invitations",
"invitation_parsing_desc": "Detect calendar invitations in email attachments and show calendar actions"
}
},
"language_region": { "language_region": {
"title": "Language & Region", "title": "Language & Region",
"description": "Configure language and regional preferences", "description": "Configure language and regional preferences",
+36 -1
View File
@@ -624,7 +624,8 @@
"encryption": "Cifrado", "encryption": "Cifrado",
"files": "Archivos", "files": "Archivos",
"contacts": "Contacts", "contacts": "Contacts",
"sidebar_apps": "Apps de barra lateral" "sidebar_apps": "Apps de barra lateral",
"notifications": "Notificaciones"
}, },
"tab_groups": { "tab_groups": {
"general": "General", "general": "General",
@@ -696,6 +697,40 @@
"migrating": "Actualizando etiqueta en correos existentes…", "migrating": "Actualizando etiqueta en correos existentes…",
"migration_error": "Error al actualizar la etiqueta en correos existentes" "migration_error": "Error al actualizar la etiqueta en correos existentes"
}, },
"notifications": {
"test_sound": "Probar sonido de notificación",
"sounds": {
"default": "Predeterminado (Pitido)",
"cheerful": "Alegre",
"involved": "Elaborado",
"swift": "Gesto rápido",
"relax": "Relajado"
},
"sound_selection": {
"title": "Sonido de notificación",
"description": "Elige qué sonido reproducir para las notificaciones",
"choose": "Sonido",
"choose_desc": "Selecciona un tono de notificación y haz clic en el icono del altavoz para previsualizarlo"
},
"email": {
"title": "Notificaciones de correo",
"description": "Configurar notificaciones para correos entrantes",
"enabled": "Notificaciones de correo",
"enabled_desc": "Mostrar notificaciones cuando lleguen nuevos correos",
"sound": "Sonido de notificación",
"sound_desc": "Reproducir un sonido cuando lleguen nuevos correos"
},
"calendar": {
"title": "Notificaciones de calendario",
"description": "Configurar notificaciones para eventos del calendario",
"enabled": "Notificaciones de eventos",
"enabled_desc": "Mostrar alertas para próximos eventos del calendario",
"sound": "Sonido de notificación",
"sound_desc": "Reproducir un sonido para recordatorios del calendario",
"invitation_parsing": "Analizar invitaciones por correo",
"invitation_parsing_desc": "Detectar invitaciones de calendario en archivos adjuntos y mostrar acciones de calendario"
}
},
"language_region": { "language_region": {
"title": "Idioma y Región", "title": "Idioma y Región",
"description": "Configure las preferencias de idioma y región", "description": "Configure las preferencias de idioma y región",
+36 -1
View File
@@ -624,7 +624,8 @@
"encryption": "Chiffrement", "encryption": "Chiffrement",
"files": "Fichiers", "files": "Fichiers",
"contacts": "Contacts", "contacts": "Contacts",
"sidebar_apps": "Apps de la barre latérale" "sidebar_apps": "Apps de la barre latérale",
"notifications": "Notifications"
}, },
"tab_groups": { "tab_groups": {
"general": "Général", "general": "Général",
@@ -696,6 +697,40 @@
"migrating": "Mise à jour du mot-clé sur les e-mails existants…", "migrating": "Mise à jour du mot-clé sur les e-mails existants…",
"migration_error": "Échec de la mise à jour du mot-clé sur les e-mails existants" "migration_error": "Échec de la mise à jour du mot-clé sur les e-mails existants"
}, },
"notifications": {
"test_sound": "Tester le son de notification",
"sounds": {
"default": "Par défaut (Bip)",
"cheerful": "Joyeux",
"involved": "Élaboré",
"swift": "Geste rapide",
"relax": "Détente"
},
"sound_selection": {
"title": "Son de notification",
"description": "Choisissez le son à jouer pour les notifications",
"choose": "Son",
"choose_desc": "Sélectionnez une sonnerie et cliquez sur l'icône du haut-parleur pour l'écouter"
},
"email": {
"title": "Notifications par e-mail",
"description": "Configurer les notifications pour les e-mails entrants",
"enabled": "Notifications par e-mail",
"enabled_desc": "Afficher des notifications à l'arrivée de nouveaux e-mails",
"sound": "Son de notification",
"sound_desc": "Jouer un son à l'arrivée de nouveaux e-mails"
},
"calendar": {
"title": "Notifications de calendrier",
"description": "Configurer les notifications pour les événements du calendrier",
"enabled": "Notifications d'événements",
"enabled_desc": "Afficher des alertes pour les événements à venir",
"sound": "Son de notification",
"sound_desc": "Jouer un son pour les rappels de calendrier",
"invitation_parsing": "Analyser les invitations par e-mail",
"invitation_parsing_desc": "Détecter les invitations de calendrier dans les pièces jointes et afficher les actions de calendrier"
}
},
"language_region": { "language_region": {
"title": "Langue et région", "title": "Langue et région",
"description": "Configurez vos préférences linguistiques et régionales", "description": "Configurez vos préférences linguistiques et régionales",
+36 -1
View File
@@ -624,7 +624,8 @@
"encryption": "Cifratura", "encryption": "Cifratura",
"files": "File", "files": "File",
"contacts": "Contacts", "contacts": "Contacts",
"sidebar_apps": "App nella barra laterale" "sidebar_apps": "App nella barra laterale",
"notifications": "Notifiche"
}, },
"tab_groups": { "tab_groups": {
"general": "Generale", "general": "Generale",
@@ -696,6 +697,40 @@
"migrating": "Aggiornamento parola chiave sulle email esistenti…", "migrating": "Aggiornamento parola chiave sulle email esistenti…",
"migration_error": "Impossibile aggiornare la parola chiave sulle email esistenti" "migration_error": "Impossibile aggiornare la parola chiave sulle email esistenti"
}, },
"notifications": {
"test_sound": "Testa il suono di notifica",
"sounds": {
"default": "Predefinito (Bip)",
"cheerful": "Allegro",
"involved": "Elaborato",
"swift": "Gesto veloce",
"relax": "Rilassante"
},
"sound_selection": {
"title": "Suono di notifica",
"description": "Scegli quale suono riprodurre per le notifiche",
"choose": "Suono",
"choose_desc": "Seleziona un tono di notifica e clicca sull'icona dell'altoparlante per l'anteprima"
},
"email": {
"title": "Notifiche e-mail",
"description": "Configura le notifiche per le e-mail in arrivo",
"enabled": "Notifiche e-mail",
"enabled_desc": "Mostra notifiche all'arrivo di nuove e-mail",
"sound": "Suono di notifica",
"sound_desc": "Riproduci un suono all'arrivo di nuove e-mail"
},
"calendar": {
"title": "Notifiche calendario",
"description": "Configura le notifiche per gli eventi del calendario",
"enabled": "Notifiche eventi",
"enabled_desc": "Mostra avvisi per i prossimi eventi del calendario",
"sound": "Suono di notifica",
"sound_desc": "Riproduci un suono per i promemoria del calendario",
"invitation_parsing": "Analizza inviti via e-mail",
"invitation_parsing_desc": "Rileva inviti calendario negli allegati e mostra azioni calendario"
}
},
"language_region": { "language_region": {
"title": "Lingua e regione", "title": "Lingua e regione",
"description": "Configura le preferenze di lingua e regionali", "description": "Configura le preferenze di lingua e regionali",
+36 -1
View File
@@ -624,7 +624,8 @@
"encryption": "暗号化", "encryption": "暗号化",
"files": "ファイル", "files": "ファイル",
"contacts": "Contacts", "contacts": "Contacts",
"sidebar_apps": "サイドバーアプリ" "sidebar_apps": "サイドバーアプリ",
"notifications": "通知"
}, },
"tab_groups": { "tab_groups": {
"general": "一般", "general": "一般",
@@ -696,6 +697,40 @@
"migrating": "既存のメールでキーワードを更新中…", "migrating": "既存のメールでキーワードを更新中…",
"migration_error": "既存のメールでのキーワード更新に失敗しました" "migration_error": "既存のメールでのキーワード更新に失敗しました"
}, },
"notifications": {
"test_sound": "通知音をテスト",
"sounds": {
"default": "デフォルト(ビープ)",
"cheerful": "チアフル",
"involved": "インボルブド",
"swift": "スウィフトジェスチャー",
"relax": "リラックス"
},
"sound_selection": {
"title": "通知音",
"description": "通知に使用する音を選択",
"choose": "サウンド",
"choose_desc": "通知音を選択し、スピーカーアイコンをクリックしてプレビュー"
},
"email": {
"title": "メール通知",
"description": "受信メールの通知を設定",
"enabled": "メール通知",
"enabled_desc": "新しいメールが届いたときに通知を表示",
"sound": "通知音",
"sound_desc": "新しいメールが届いたときに音を鳴らす"
},
"calendar": {
"title": "カレンダー通知",
"description": "カレンダーイベントの通知を設定",
"enabled": "イベント通知",
"enabled_desc": "今後のカレンダーイベントのアラートを表示",
"sound": "通知音",
"sound_desc": "カレンダーリマインダーの音を鳴らす",
"invitation_parsing": "メール招待を解析",
"invitation_parsing_desc": "メール添付ファイルのカレンダー招待を検出し、カレンダーアクションを表示"
}
},
"language_region": { "language_region": {
"title": "言語と地域", "title": "言語と地域",
"description": "言語と地域の設定を構成", "description": "言語と地域の設定を構成",
+36 -1
View File
@@ -624,7 +624,8 @@
"encryption": "Versleuteling", "encryption": "Versleuteling",
"files": "Bestanden", "files": "Bestanden",
"contacts": "Contacts", "contacts": "Contacts",
"sidebar_apps": "Zijbalk-apps" "sidebar_apps": "Zijbalk-apps",
"notifications": "Meldingen"
}, },
"tab_groups": { "tab_groups": {
"general": "Algemeen", "general": "Algemeen",
@@ -696,6 +697,40 @@
"migrating": "Trefwoord bijwerken op bestaande e-mails…", "migrating": "Trefwoord bijwerken op bestaande e-mails…",
"migration_error": "Kan trefwoord niet bijwerken op bestaande e-mails" "migration_error": "Kan trefwoord niet bijwerken op bestaande e-mails"
}, },
"notifications": {
"test_sound": "Meldingsgeluid testen",
"sounds": {
"default": "Standaard (Pieptoon)",
"cheerful": "Vrolijk",
"involved": "Uitgebreid",
"swift": "Snel gebaar",
"relax": "Ontspannen"
},
"sound_selection": {
"title": "Meldingsgeluid",
"description": "Kies welk geluid wordt afgespeeld voor meldingen",
"choose": "Geluid",
"choose_desc": "Selecteer een meldingstoon en klik op het luidsprekerpictogram voor een voorbeeld"
},
"email": {
"title": "E-mailmeldingen",
"description": "Meldingen voor inkomende e-mails configureren",
"enabled": "E-mailmeldingen",
"enabled_desc": "Meldingen tonen wanneer nieuwe e-mails binnenkomen",
"sound": "Meldingsgeluid",
"sound_desc": "Een geluid afspelen wanneer nieuwe e-mails binnenkomen"
},
"calendar": {
"title": "Agendameldingen",
"description": "Meldingen voor agenda-evenementen configureren",
"enabled": "Evenementmeldingen",
"enabled_desc": "Waarschuwingen tonen voor aankomende agenda-evenementen",
"sound": "Meldingsgeluid",
"sound_desc": "Een geluid afspelen voor agendaherinneringen",
"invitation_parsing": "E-mailuitnodigingen herkennen",
"invitation_parsing_desc": "Agenda-uitnodigingen in e-mailbijlagen detecteren en agendaacties tonen"
}
},
"language_region": { "language_region": {
"title": "Taal & Regio", "title": "Taal & Regio",
"description": "Configureer taal- en regiovoorkeuren", "description": "Configureer taal- en regiovoorkeuren",
+36 -1
View File
@@ -624,7 +624,8 @@
"encryption": "Criptografia", "encryption": "Criptografia",
"files": "Arquivos", "files": "Arquivos",
"contacts": "Contacts", "contacts": "Contacts",
"sidebar_apps": "Apps da barra lateral" "sidebar_apps": "Apps da barra lateral",
"notifications": "Notificações"
}, },
"tab_groups": { "tab_groups": {
"general": "Geral", "general": "Geral",
@@ -696,6 +697,40 @@
"migrating": "Atualizando etiqueta nos e-mails existentes…", "migrating": "Atualizando etiqueta nos e-mails existentes…",
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes" "migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
}, },
"notifications": {
"test_sound": "Testar som de notificação",
"sounds": {
"default": "Padrão (Bipe)",
"cheerful": "Alegre",
"involved": "Elaborado",
"swift": "Gesto rápido",
"relax": "Relaxante"
},
"sound_selection": {
"title": "Som de notificação",
"description": "Escolha qual som reproduzir para notificações",
"choose": "Som",
"choose_desc": "Selecione um toque de notificação e clique no ícone do alto-falante para pré-visualizar"
},
"email": {
"title": "Notificações de e-mail",
"description": "Configurar notificações para e-mails recebidos",
"enabled": "Notificações de e-mail",
"enabled_desc": "Mostrar notificações quando novos e-mails chegarem",
"sound": "Som de notificação",
"sound_desc": "Reproduzir um som quando novos e-mails chegarem"
},
"calendar": {
"title": "Notificações de calendário",
"description": "Configurar notificações para eventos do calendário",
"enabled": "Notificações de eventos",
"enabled_desc": "Mostrar alertas para próximos eventos do calendário",
"sound": "Som de notificação",
"sound_desc": "Reproduzir um som para lembretes do calendário",
"invitation_parsing": "Analisar convites por e-mail",
"invitation_parsing_desc": "Detectar convites de calendário em anexos de e-mail e mostrar ações de calendário"
}
},
"language_region": { "language_region": {
"title": "Idioma e Região", "title": "Idioma e Região",
"description": "Configure preferências de idioma e região", "description": "Configure preferências de idioma e região",
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { useThemeStore } from './theme-store'; import { useThemeStore } from './theme-store';
import { useLocaleStore } from './locale-store'; import { useLocaleStore } from './locale-store';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
// Use console directly to avoid circular dependency with lib/debug.ts // Use console directly to avoid circular dependency with lib/debug.ts
// (debug.ts imports useSettingsStore for debugMode check) // (debug.ts imports useSettingsStore for debugMode check)
@@ -130,6 +131,11 @@ interface SettingsState {
enableCalendarTasks: boolean; enableCalendarTasks: boolean;
showTasksOnCalendar: boolean; showTasksOnCalendar: boolean;
// Email Notifications
emailNotificationsEnabled: boolean;
emailNotificationSound: boolean;
notificationSoundChoice: NotificationSoundChoice;
// Calendar Notifications // Calendar Notifications
calendarNotificationsEnabled: boolean; calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean; calendarNotificationSound: boolean;
@@ -238,6 +244,11 @@ const DEFAULT_SETTINGS = {
enableCalendarTasks: false, enableCalendarTasks: false,
showTasksOnCalendar: true, showTasksOnCalendar: true,
// Email Notifications
emailNotificationsEnabled: true,
emailNotificationSound: true,
notificationSoundChoice: 'default' as NotificationSoundChoice,
// Calendar Notifications // Calendar Notifications
calendarNotificationsEnabled: true, calendarNotificationsEnabled: true,
calendarNotificationSound: true, calendarNotificationSound: true,
@@ -319,6 +330,9 @@ export const useSettingsStore = create<SettingsState>()(
sendConfirmation: state.sendConfirmation, sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode, defaultReplyMode: state.defaultReplyMode,
sessionTimeout: state.sessionTimeout, sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,
notificationSoundChoice: state.notificationSoundChoice,
calendarNotificationsEnabled: state.calendarNotificationsEnabled, calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound, calendarNotificationSound: state.calendarNotificationSound,
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled, calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,