refactor(settings): share the notification settings logic between desktop and phone

The preference matrix, the webhook and ntfy credentials and the channel test sends now live in one useNotificationSettings hook. The phone keeps sending the whole matrix on a toggle and skipping a token typed as the mask, through two options.
This commit is contained in:
Maurice
2026-10-09 10:05:31 +02:00
parent a8dc000e7e
commit d042a18d0a
4 changed files with 484 additions and 376 deletions
@@ -1,210 +1,26 @@
import React, { useState, useEffect } from 'react'
import React from 'react'
import { Link } from 'react-router'
import { Bell, Lock, Plug, Send, Webhook } from 'lucide-react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { notificationsApi, settingsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import ToggleSwitch from './ToggleSwitch'
import Section from './Section'
import WebPushCard from './WebPushCard'
import { EVENT_LABEL_KEYS, channelLabel, isLockedCell } from './notificationLabels'
import { useNotificationSettings } from './useNotificationSettings'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
interface ChannelDescriptor {
id: string
source: 'builtin' | 'plugin'
/** Built-ins: an i18n key. */
labelKey?: string
/** Plugin channels: a literal name, already resolved by the server. */
label?: string
settingsPath?: string
active: boolean
configured: boolean
}
interface PreferencesMatrix {
preferences: Record<string, Record<string, boolean>>
channels: ChannelDescriptor[]
event_types: string[]
implemented_combos: Record<string, string[]>
/** Cells the admin switched off for everyone (#1536). */
locked?: Record<string, string[]>
defaults?: { ntfyServer: string | null }
}
export default function NotificationsTab(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
const [saving, setSaving] = useState(false)
const [webhookUrl, setWebhookUrl] = useState('')
const [webhookIsSet, setWebhookIsSet] = useState(false)
const [webhookSaving, setWebhookSaving] = useState(false)
const [webhookTesting, setWebhookTesting] = useState(false)
const [ntfyTopic, setNtfyTopic] = useState('')
const [ntfyServer, setNtfyServer] = useState('')
const [ntfyToken, setNtfyToken] = useState('')
const [ntfyTokenIsSet, setNtfyTokenIsSet] = useState(false)
const [ntfySaving, setNtfySaving] = useState(false)
const [ntfyTesting, setNtfyTesting] = useState(false)
const [channelTesting, setChannelTesting] = useState<string | null>(null)
useEffect(() => {
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
const val = (data.settings?.webhook_url as string) || ''
if (val === '••••••••') {
setWebhookIsSet(true)
setWebhookUrl('')
} else {
setWebhookUrl(val)
}
setNtfyTopic((data.settings?.ntfy_topic as string) || '')
setNtfyServer((data.settings?.ntfy_server as string) || '')
const rawToken = (data.settings?.ntfy_token as string) || ''
if (rawToken === '••••••••') {
setNtfyTokenIsSet(true)
setNtfyToken('')
} else {
setNtfyToken(rawToken)
}
}).catch(() => {})
}, [])
// Columns are whatever the server says exists and the admin turned on — so a
// plugin channel gets a column here with no client change.
const visibleChannels = matrix
? matrix.channels.filter(ch => {
if (!ch.active) return false
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch.id))
})
: []
const hasChannel = (id: string) => matrix?.channels.some(ch => ch.id === id && ch.active) ?? false
// Plugin channels have no bespoke credential form here — the user fills those in on the
// plugin's own settings page. What they DO get is a test send, through the generic route.
const pluginChannels = matrix?.channels.filter(ch => ch.source === 'plugin' && ch.active) ?? []
const testChannel = async (ch: ChannelDescriptor) => {
setChannelTesting(ch.id)
try {
const result = await notificationsApi.testChannel(ch.id)
if (result.success) toast.success(t('settings.notificationPreferences.testSuccess'))
else toast.error(result.error || t('settings.notificationPreferences.testFailed'))
} catch {
toast.error(t('settings.notificationPreferences.testFailed'))
} finally {
setChannelTesting(null)
}
}
const toggle = async (eventType: string, channel: string) => {
if (!matrix || isLockedCell(matrix.locked, eventType, channel)) return
const current = matrix.preferences[eventType]?.[channel] ?? true
setMatrix(m => m ? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: !current } },
} : m)
setSaving(true)
try {
// Only the toggled cell goes out. The server merges what it gets, and sending the
// whole matrix would carry this render's value for every other cell — a second
// toggle made while this request is in flight would be overwritten by it.
await notificationsApi.updatePreferences({ [eventType]: { [channel]: !current } })
} catch {
// Only this cell rolls back — restoring the whole snapshot would also undo a
// toggle the user made while this request was in flight.
setMatrix(m => m ? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: current } },
} : m)
toast.error(t('common.error'))
} finally {
setSaving(false)
}
}
const saveWebhookUrl = async () => {
setWebhookSaving(true)
try {
await settingsApi.set('webhook_url', webhookUrl)
if (webhookUrl) setWebhookIsSet(true)
else setWebhookIsSet(false)
toast.success(t('settings.webhookUrl.saved'))
} catch {
toast.error(t('common.error'))
} finally {
setWebhookSaving(false)
}
}
const testWebhookUrl = async () => {
if (!webhookUrl && !webhookIsSet) return
setWebhookTesting(true)
try {
const result = await notificationsApi.testWebhook(webhookUrl || undefined)
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
} catch {
toast.error(t('settings.webhookUrl.testFailed'))
} finally {
setWebhookTesting(false)
}
}
const saveNtfySettings = async () => {
setNtfySaving(true)
try {
await settingsApi.setBulk({
ntfy_topic: ntfyTopic,
ntfy_server: ntfyServer,
...(ntfyToken ? { ntfy_token: ntfyToken } : {}),
})
if (ntfyToken) setNtfyTokenIsSet(true)
toast.success(t('settings.ntfyUrl.saved'))
} catch {
toast.error(t('common.error'))
} finally {
setNtfySaving(false)
}
}
const clearNtfyToken = async () => {
try {
await settingsApi.set('ntfy_token', '')
setNtfyToken('')
setNtfyTokenIsSet(false)
toast.success(t('settings.ntfyUrl.tokenCleared'))
} catch {
toast.error(t('common.error'))
}
}
const testNtfySettings = async () => {
if (!ntfyTopic) return
setNtfyTesting(true)
try {
const result = await notificationsApi.testNtfy({
topic: ntfyTopic,
server: ntfyServer || null,
// A stored token is only masked in the placeholder, never in state — sending
// null makes the server fall back to the saved one.
token: ntfyToken || null,
})
if (result.success) toast.success(t('settings.ntfyUrl.testSuccess'))
else toast.error(result.error || t('settings.ntfyUrl.testFailed'))
} catch {
toast.error(t('settings.ntfyUrl.testFailed'))
} finally {
setNtfyTesting(false)
}
}
const {
matrix, saving, visibleChannels, hasChannel, pluginChannels, toggle, testChannel, channelTesting,
webhookUrl, setWebhookUrl, webhookIsSet, webhookSaving, webhookTesting, saveWebhookUrl, testWebhookUrl,
ntfyTopic, setNtfyTopic, ntfyServer, setNtfyServer, ntfyToken, setNtfyToken, ntfyTokenIsSet,
ntfySaving, ntfyTesting, saveNtfySettings, clearNtfyToken, testNtfySettings,
} = useNotificationSettings()
const renderMatrix = () => {
if (!matrix) return <MatrixSkeleton label={t('common.loading')} />
@@ -0,0 +1,194 @@
// FE-COMP-NOTIFSETTINGS-001 onwards: the notification settings behind both settings shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import { notificationsApi, settingsApi } from '../../api/client';
import { MASKED, type PreferencesMatrix, useNotificationSettings } from './useNotificationSettings';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
const MATRIX: PreferencesMatrix = {
preferences: { trip_invite: { inapp: true, email: false } },
channels: [
{ id: 'inapp', source: 'builtin', labelKey: 'x', active: true, configured: true },
{ id: 'email', source: 'builtin', labelKey: 'y', active: true, configured: true },
{ id: 'webhook', source: 'builtin', labelKey: 'z', active: false, configured: true },
{ id: 'gotify', source: 'plugin', label: 'Gotify', active: true, configured: false },
],
event_types: ['trip_invite', 'todo_due'],
implemented_combos: { trip_invite: ['inapp', 'email'], todo_due: ['inapp'] },
locked: { todo_due: ['inapp'] },
};
function stubLoad(settings: Record<string, unknown> = {}) {
vi.spyOn(notificationsApi, 'getPreferences').mockResolvedValue(
JSON.parse(JSON.stringify(MATRIX)) as PreferencesMatrix
);
vi.spyOn(settingsApi, 'get').mockResolvedValue({ settings });
}
async function loaded(options?: Parameters<typeof useNotificationSettings>[0]) {
const hook = renderHook(() => useNotificationSettings(options));
await waitFor(() => expect(hook.result.current.matrix).not.toBeNull());
return hook;
}
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
});
afterEach(() => vi.restoreAllMocks());
describe('useNotificationSettings', () => {
it('FE-COMP-NOTIFSETTINGS-001: loads the matrix and derives visible, active and plugin channels', async () => {
stubLoad();
const { result } = await loaded();
expect(result.current.visibleChannels.map((c) => c.id)).toEqual(['inapp', 'email']);
expect(result.current.hasChannel('inapp')).toBe(true);
expect(result.current.hasChannel('webhook')).toBe(false);
expect(result.current.pluginChannels.map((c) => c.id)).toEqual(['gotify']);
});
it('FE-COMP-NOTIFSETTINGS-002: a masked webhook and token load as set but empty, plain values load as typed', async () => {
stubLoad({ webhook_url: MASKED, ntfy_topic: 'alerts', ntfy_server: 'https://n.example', ntfy_token: MASKED });
const { result } = await loaded();
await waitFor(() => expect(result.current.webhookIsSet).toBe(true));
expect(result.current.webhookUrl).toBe('');
expect(result.current.ntfyTokenIsSet).toBe(true);
expect(result.current.ntfyToken).toBe('');
expect(result.current.ntfyTopic).toBe('alerts');
expect(result.current.ntfyServer).toBe('https://n.example');
stubLoad({ webhook_url: 'https://h.example', ntfy_token: 'tk' });
const second = await loaded();
await waitFor(() => expect(second.result.current.webhookUrl).toBe('https://h.example'));
expect(second.result.current.webhookIsSet).toBe(false);
expect(second.result.current.ntfyToken).toBe('tk');
});
it('FE-COMP-NOTIFSETTINGS-003: the desktop toggle sends only the toggled cell', async () => {
stubLoad();
const update = vi.spyOn(notificationsApi, 'updatePreferences').mockResolvedValue({});
const { result } = await loaded();
await act(() => result.current.toggle('trip_invite', 'email'));
expect(update).toHaveBeenCalledWith({ trip_invite: { email: true } });
expect(result.current.matrix?.preferences.trip_invite).toEqual({ inapp: true, email: true });
expect(result.current.saving).toBe(false);
});
it('FE-COMP-NOTIFSETTINGS-004: the phone toggle sends the whole matrix', async () => {
stubLoad();
const update = vi.spyOn(notificationsApi, 'updatePreferences').mockResolvedValue({});
const { result } = await loaded({ sendWholeMatrix: true });
await act(() => result.current.toggle('trip_invite', 'inapp'));
expect(update).toHaveBeenCalledWith({ trip_invite: { inapp: false, email: false } });
expect(result.current.matrix?.preferences.trip_invite).toEqual({ inapp: false, email: false });
});
it('FE-COMP-NOTIFSETTINGS-005: a failed toggle rolls only its cell back with an error toast; a locked cell sends nothing', async () => {
stubLoad();
const update = vi.spyOn(notificationsApi, 'updatePreferences').mockRejectedValue(new Error('x'));
const { result } = await loaded();
await act(() => result.current.toggle('trip_invite', 'email'));
expect(result.current.matrix?.preferences.trip_invite.email).toBe(false);
expect(toast.error).toHaveBeenCalledWith('common.error');
update.mockClear();
await act(() => result.current.toggle('todo_due', 'inapp'));
expect(update).not.toHaveBeenCalled();
});
it('FE-COMP-NOTIFSETTINGS-006: webhook save marks it set or unset, and its test reports the server verdict', async () => {
stubLoad();
const set = vi.spyOn(settingsApi, 'set').mockResolvedValue({});
const test = vi.spyOn(notificationsApi, 'testWebhook').mockResolvedValue({ success: false, error: 'nope' });
const { result } = await loaded();
await act(() => result.current.testWebhookUrl());
expect(test).not.toHaveBeenCalled();
act(() => result.current.setWebhookUrl('https://h.example'));
await act(() => result.current.saveWebhookUrl());
expect(set).toHaveBeenCalledWith('webhook_url', 'https://h.example');
expect(result.current.webhookIsSet).toBe(true);
expect(toast.success).toHaveBeenCalledWith('settings.webhookUrl.saved');
await act(() => result.current.testWebhookUrl());
expect(test).toHaveBeenCalledWith('https://h.example');
expect(toast.error).toHaveBeenCalledWith('nope');
act(() => result.current.setWebhookUrl(''));
await act(() => result.current.saveWebhookUrl());
expect(result.current.webhookIsSet).toBe(false);
});
it('FE-COMP-NOTIFSETTINGS-007: ntfy save and test send the token only when one was typed', async () => {
stubLoad();
const bulk = vi.spyOn(settingsApi, 'setBulk').mockResolvedValue({});
const test = vi.spyOn(notificationsApi, 'testNtfy').mockResolvedValue({ success: true });
const { result } = await loaded();
await act(() => result.current.testNtfySettings());
expect(test).not.toHaveBeenCalled();
act(() => result.current.setNtfyTopic('alerts'));
await act(() => result.current.saveNtfySettings());
expect(bulk).toHaveBeenCalledWith({ ntfy_topic: 'alerts', ntfy_server: '' });
expect(result.current.ntfyTokenIsSet).toBe(false);
act(() => result.current.setNtfyToken('tk'));
await act(() => result.current.saveNtfySettings());
expect(bulk).toHaveBeenLastCalledWith({ ntfy_topic: 'alerts', ntfy_server: '', ntfy_token: 'tk' });
expect(result.current.ntfyTokenIsSet).toBe(true);
await act(() => result.current.testNtfySettings());
expect(test).toHaveBeenCalledWith({ topic: 'alerts', server: null, token: 'tk' });
expect(toast.success).toHaveBeenCalledWith('settings.ntfyUrl.testSuccess');
});
it('FE-COMP-NOTIFSETTINGS-008: a token typed as the mask is sent by the desktop but skipped by the phone', async () => {
stubLoad();
const bulk = vi.spyOn(settingsApi, 'setBulk').mockResolvedValue({});
const test = vi.spyOn(notificationsApi, 'testNtfy').mockResolvedValue({ success: true });
const desktop = await loaded();
act(() => {
desktop.result.current.setNtfyTopic('a');
desktop.result.current.setNtfyToken(MASKED);
});
await act(() => desktop.result.current.saveNtfySettings());
expect(bulk).toHaveBeenLastCalledWith({ ntfy_topic: 'a', ntfy_server: '', ntfy_token: MASKED });
await act(() => desktop.result.current.testNtfySettings());
expect(test).toHaveBeenLastCalledWith({ topic: 'a', server: null, token: MASKED });
const phone = await loaded({ skipMaskedToken: true });
act(() => {
phone.result.current.setNtfyTopic('a');
phone.result.current.setNtfyToken(MASKED);
});
await act(() => phone.result.current.saveNtfySettings());
expect(bulk).toHaveBeenLastCalledWith({ ntfy_topic: 'a', ntfy_server: '' });
expect(phone.result.current.ntfyTokenIsSet).toBe(false);
await act(() => phone.result.current.testNtfySettings());
expect(test).toHaveBeenLastCalledWith({ topic: 'a', server: null, token: null });
});
it('FE-COMP-NOTIFSETTINGS-009: clearing the token and testing a plugin channel', async () => {
stubLoad({ ntfy_token: MASKED });
vi.spyOn(settingsApi, 'set').mockResolvedValue({});
const testChannel = vi.spyOn(notificationsApi, 'testChannel').mockRejectedValue(new Error('x'));
const { result } = await loaded();
await waitFor(() => expect(result.current.ntfyTokenIsSet).toBe(true));
await act(() => result.current.clearNtfyToken());
expect(settingsApi.set).toHaveBeenCalledWith('ntfy_token', '');
expect(result.current.ntfyTokenIsSet).toBe(false);
expect(toast.success).toHaveBeenCalledWith('settings.ntfyUrl.tokenCleared');
await act(() => result.current.testChannel(MATRIX.channels[3]));
expect(testChannel).toHaveBeenCalledWith('gotify');
expect(toast.error).toHaveBeenCalledWith('settings.notificationPreferences.testFailed');
expect(result.current.channelTesting).toBeNull();
});
});
@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react';
import { notificationsApi, settingsApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useToast } from '../shared/Toast';
import { isLockedCell } from './notificationLabels';
export interface ChannelDescriptor {
id: string;
source: 'builtin' | 'plugin';
/** Built-ins: an i18n key. */
labelKey?: string;
/** Plugin channels: a literal name, already resolved by the server. */
label?: string;
settingsPath?: string;
active: boolean;
configured: boolean;
}
export interface PreferencesMatrix {
preferences: Record<string, Record<string, boolean>>;
channels: ChannelDescriptor[];
event_types: string[];
implemented_combos: Record<string, string[]>;
/** Cells the admin switched off for everyone (#1536). */
locked?: Record<string, string[]>;
defaults?: { ntfyServer: string | null };
}
/** What the server sends back in place of a stored secret. */
export const MASKED = '••••••••';
export interface NotificationSettingsOptions {
/**
* The phone sends the whole preference matrix on a toggle; the desktop sends only
* the toggled cell, so a second toggle in flight cannot be overwritten by it.
*/
sendWholeMatrix?: boolean;
/** The phone never sends an ntfy token that reads exactly like the mask. */
skipMaskedToken?: boolean;
}
/**
* The notification settings behind both settings shells (the desktop tab and the
* phone section render their own markup over this): the event/channel matrix, the
* webhook and ntfy credentials, and the test sends for every channel.
*/
export function useNotificationSettings({
sendWholeMatrix = false,
skipMaskedToken = false,
}: NotificationSettingsOptions = {}) {
const { t } = useTranslation();
const toast = useToast();
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null);
const [saving, setSaving] = useState(false);
const [webhookUrl, setWebhookUrl] = useState('');
const [webhookIsSet, setWebhookIsSet] = useState(false);
const [webhookSaving, setWebhookSaving] = useState(false);
const [webhookTesting, setWebhookTesting] = useState(false);
const [ntfyTopic, setNtfyTopic] = useState('');
const [ntfyServer, setNtfyServer] = useState('');
const [ntfyToken, setNtfyToken] = useState('');
const [ntfyTokenIsSet, setNtfyTokenIsSet] = useState(false);
const [ntfySaving, setNtfySaving] = useState(false);
const [ntfyTesting, setNtfyTesting] = useState(false);
const [channelTesting, setChannelTesting] = useState<string | null>(null);
useEffect(() => {
notificationsApi
.getPreferences()
.then((data: PreferencesMatrix) => setMatrix(data))
.catch(() => {});
settingsApi
.get()
.then((data: { settings: Record<string, unknown> }) => {
const val = (data.settings?.webhook_url as string) || '';
if (val === MASKED) {
setWebhookIsSet(true);
setWebhookUrl('');
} else {
setWebhookUrl(val);
}
setNtfyTopic((data.settings?.ntfy_topic as string) || '');
setNtfyServer((data.settings?.ntfy_server as string) || '');
const rawToken = (data.settings?.ntfy_token as string) || '';
if (rawToken === MASKED) {
setNtfyTokenIsSet(true);
setNtfyToken('');
} else {
setNtfyToken(rawToken);
}
})
.catch(() => {});
}, []);
// Columns are whatever the server says exists and the admin turned on, so a
// plugin channel gets a column here with no client change.
const visibleChannels = matrix
? matrix.channels.filter((ch) => {
if (!ch.active) return false;
return matrix.event_types.some((evt) => matrix.implemented_combos[evt]?.includes(ch.id));
})
: [];
const hasChannel = (id: string) => matrix?.channels.some((ch) => ch.id === id && ch.active) ?? false;
// Plugin channels have no bespoke credential form here; the user fills those in on the
// plugin's own settings page. What they DO get is a test send, through the generic route.
const pluginChannels = matrix?.channels.filter((ch) => ch.source === 'plugin' && ch.active) ?? [];
// A stored token is only masked in the placeholder, never in state, so an empty
// token makes the server fall back to the saved one.
const tokenToSend = skipMaskedToken ? ntfyToken && ntfyToken !== MASKED : ntfyToken;
const testChannel = async (ch: ChannelDescriptor) => {
setChannelTesting(ch.id);
try {
const result = await notificationsApi.testChannel(ch.id);
if (result.success) toast.success(t('settings.notificationPreferences.testSuccess'));
else toast.error(result.error || t('settings.notificationPreferences.testFailed'));
} catch {
toast.error(t('settings.notificationPreferences.testFailed'));
} finally {
setChannelTesting(null);
}
};
const toggle = async (eventType: string, channel: string) => {
if (!matrix || isLockedCell(matrix.locked, eventType, channel)) return;
const current = matrix.preferences[eventType]?.[channel] ?? true;
let payload: Record<string, Record<string, boolean>>;
if (sendWholeMatrix) {
const updated = {
...matrix.preferences,
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
};
setMatrix((m) => (m ? { ...m, preferences: updated } : m));
payload = updated;
} else {
setMatrix((m) =>
m
? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: !current } },
}
: m
);
// Only the toggled cell goes out. The server merges what it gets, and sending the
// whole matrix would carry this render's value for every other cell: a second
// toggle made while this request is in flight would be overwritten by it.
payload = { [eventType]: { [channel]: !current } };
}
setSaving(true);
try {
await notificationsApi.updatePreferences(payload);
} catch {
// Only this cell rolls back: restoring the whole snapshot would also undo a
// toggle the user made while this request was in flight.
setMatrix((m) =>
m
? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: current } },
}
: m
);
toast.error(t('common.error'));
} finally {
setSaving(false);
}
};
const saveWebhookUrl = async () => {
setWebhookSaving(true);
try {
await settingsApi.set('webhook_url', webhookUrl);
setWebhookIsSet(!!webhookUrl);
toast.success(t('settings.webhookUrl.saved'));
} catch {
toast.error(t('common.error'));
} finally {
setWebhookSaving(false);
}
};
const testWebhookUrl = async () => {
if (!webhookUrl && !webhookIsSet) return;
setWebhookTesting(true);
try {
const result = await notificationsApi.testWebhook(webhookUrl || undefined);
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'));
else toast.error(result.error || t('settings.webhookUrl.testFailed'));
} catch {
toast.error(t('settings.webhookUrl.testFailed'));
} finally {
setWebhookTesting(false);
}
};
const saveNtfySettings = async () => {
setNtfySaving(true);
try {
await settingsApi.setBulk({
ntfy_topic: ntfyTopic,
ntfy_server: ntfyServer,
...(tokenToSend ? { ntfy_token: ntfyToken } : {}),
});
if (tokenToSend) setNtfyTokenIsSet(true);
toast.success(t('settings.ntfyUrl.saved'));
} catch {
toast.error(t('common.error'));
} finally {
setNtfySaving(false);
}
};
const clearNtfyToken = async () => {
try {
await settingsApi.set('ntfy_token', '');
setNtfyToken('');
setNtfyTokenIsSet(false);
toast.success(t('settings.ntfyUrl.tokenCleared'));
} catch {
toast.error(t('common.error'));
}
};
const testNtfySettings = async () => {
if (!ntfyTopic) return;
setNtfyTesting(true);
try {
const result = await notificationsApi.testNtfy({
topic: ntfyTopic,
server: ntfyServer || null,
token: tokenToSend ? ntfyToken : null,
});
if (result.success) toast.success(t('settings.ntfyUrl.testSuccess'));
else toast.error(result.error || t('settings.ntfyUrl.testFailed'));
} catch {
toast.error(t('settings.ntfyUrl.testFailed'));
} finally {
setNtfyTesting(false);
}
};
return {
matrix,
saving,
visibleChannels,
hasChannel,
pluginChannels,
toggle,
testChannel,
channelTesting,
webhookUrl,
setWebhookUrl,
webhookIsSet,
webhookSaving,
webhookTesting,
saveWebhookUrl,
testWebhookUrl,
ntfyTopic,
setNtfyTopic,
ntfyServer,
setNtfyServer,
ntfyToken,
setNtfyToken,
ntfyTokenIsSet,
ntfySaving,
ntfyTesting,
saveNtfySettings,
clearNtfyToken,
testNtfySettings,
};
}
@@ -1,39 +1,12 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Bell, Link2, Lock, Send } from 'lucide-react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { notificationsApi, settingsApi } from '../../../api/client'
import { useToast } from '../../../components/shared/Toast'
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MChip from '../../components/MChip'
import MWebPushCard from './MWebPushCard'
import { EVENT_LABEL_KEYS, channelLabel, isLockedCell } from '../../../components/Settings/notificationLabels'
interface ChannelDescriptor {
id: string
source: 'builtin' | 'plugin'
/** Built-ins: an i18n key. */
labelKey?: string
/** Plugin channels: a literal name, already resolved by the server. */
label?: string
settingsPath?: string
active: boolean
configured: boolean
}
interface PreferencesMatrix {
preferences: Record<string, Record<string, boolean>>
channels: ChannelDescriptor[]
event_types: string[]
implemented_combos: Record<string, string[]>
/** Cells the admin switched off for everyone (#1536). */
locked?: Record<string, string[]>
defaults?: { ntfyServer: string | null }
}
const MASKED = '••••••••'
import { MASKED, useNotificationSettings } from '../../../components/Settings/useNotificationSettings'
/**
* "Notifications" section — NotificationsTab parity: webhook + ntfy channel
@@ -42,163 +15,13 @@ const MASKED = '••••••••'
*/
export default function MSettingsNotifications() {
const { t } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
const [saving, setSaving] = useState(false)
const [webhookUrl, setWebhookUrl] = useState('')
const [webhookIsSet, setWebhookIsSet] = useState(false)
const [webhookSaving, setWebhookSaving] = useState(false)
const [webhookTesting, setWebhookTesting] = useState(false)
const [ntfyTopic, setNtfyTopic] = useState('')
const [ntfyServer, setNtfyServer] = useState('')
const [ntfyToken, setNtfyToken] = useState('')
const [ntfyTokenIsSet, setNtfyTokenIsSet] = useState(false)
const [ntfySaving, setNtfySaving] = useState(false)
const [ntfyTesting, setNtfyTesting] = useState(false)
const [channelTesting, setChannelTesting] = useState<string | null>(null)
useEffect(() => {
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
const val = (data.settings?.webhook_url as string) || ''
if (val === MASKED) {
setWebhookIsSet(true)
setWebhookUrl('')
} else {
setWebhookUrl(val)
}
setNtfyTopic((data.settings?.ntfy_topic as string) || '')
setNtfyServer((data.settings?.ntfy_server as string) || '')
const rawToken = (data.settings?.ntfy_token as string) || ''
if (rawToken === MASKED) {
setNtfyTokenIsSet(true)
setNtfyToken('')
} else {
setNtfyToken(rawToken)
}
}).catch(() => {})
}, [])
const visibleChannels = matrix
? matrix.channels.filter((ch) => {
if (!ch.active) return false
return matrix.event_types.some((evt) => matrix.implemented_combos[evt]?.includes(ch.id))
})
: []
const hasChannel = (id: string) => matrix?.channels.some((ch) => ch.id === id && ch.active) ?? false
const pluginChannels = matrix?.channels.filter((ch) => ch.source === 'plugin' && ch.active) ?? []
const testChannel = async (ch: ChannelDescriptor) => {
setChannelTesting(ch.id)
try {
const result = await notificationsApi.testChannel(ch.id)
if (result.success) toast.success(t('settings.notificationPreferences.testSuccess'))
else toast.error(result.error || t('settings.notificationPreferences.testFailed'))
} catch {
toast.error(t('settings.notificationPreferences.testFailed'))
} finally {
setChannelTesting(null)
}
}
const toggle = async (eventType: string, channel: string) => {
if (!matrix || isLockedCell(matrix.locked, eventType, channel)) return
const current = matrix.preferences[eventType]?.[channel] ?? true
const updated = {
...matrix.preferences,
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
}
setMatrix((m) => (m ? { ...m, preferences: updated } : m))
setSaving(true)
try {
await notificationsApi.updatePreferences(updated)
} catch {
// Only this cell rolls back — restoring the whole snapshot would also undo a
// toggle the user made while this request was in flight.
setMatrix((m) => (m ? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: current } },
} : m))
toast.error(t('common.error'))
} finally {
setSaving(false)
}
}
const saveWebhookUrl = async () => {
setWebhookSaving(true)
try {
await settingsApi.set('webhook_url', webhookUrl)
setWebhookIsSet(!!webhookUrl)
toast.success(t('settings.webhookUrl.saved'))
} catch {
toast.error(t('common.error'))
} finally {
setWebhookSaving(false)
}
}
const testWebhookUrl = async () => {
if (!webhookUrl && !webhookIsSet) return
setWebhookTesting(true)
try {
const result = await notificationsApi.testWebhook(webhookUrl || undefined)
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
} catch {
toast.error(t('settings.webhookUrl.testFailed'))
} finally {
setWebhookTesting(false)
}
}
const saveNtfySettings = async () => {
setNtfySaving(true)
try {
await settingsApi.setBulk({
ntfy_topic: ntfyTopic,
ntfy_server: ntfyServer,
...(ntfyToken && ntfyToken !== MASKED ? { ntfy_token: ntfyToken } : {}),
})
if (ntfyToken && ntfyToken !== MASKED) setNtfyTokenIsSet(true)
toast.success(t('settings.ntfyUrl.saved'))
} catch {
toast.error(t('common.error'))
} finally {
setNtfySaving(false)
}
}
const clearNtfyToken = async () => {
try {
await settingsApi.set('ntfy_token', '')
setNtfyToken('')
setNtfyTokenIsSet(false)
toast.success(t('settings.ntfyUrl.tokenCleared'))
} catch {
toast.error(t('common.error'))
}
}
const testNtfySettings = async () => {
if (!ntfyTopic) return
setNtfyTesting(true)
try {
const result = await notificationsApi.testNtfy({
topic: ntfyTopic,
server: ntfyServer || null,
token: ntfyToken && ntfyToken !== MASKED ? ntfyToken : null,
})
if (result.success) toast.success(t('settings.ntfyUrl.testSuccess'))
else toast.error(result.error || t('settings.ntfyUrl.testFailed'))
} catch {
toast.error(t('settings.ntfyUrl.testFailed'))
} finally {
setNtfyTesting(false)
}
}
const {
matrix, saving, visibleChannels, hasChannel, pluginChannels, toggle, testChannel, channelTesting,
webhookUrl, setWebhookUrl, webhookIsSet, webhookSaving, webhookTesting, saveWebhookUrl, testWebhookUrl,
ntfyTopic, setNtfyTopic, ntfyServer, setNtfyServer, ntfyToken, setNtfyToken, ntfyTokenIsSet,
ntfySaving, ntfyTesting, saveNtfySettings, clearNtfyToken, testNtfySettings,
} = useNotificationSettings({ sendWholeMatrix: true, skipMaskedToken: true })
return (
<MSetCard title={t('settings.notifications')} icon={Bell}>