diff --git a/client/src/components/Settings/NotificationsTab.tsx b/client/src/components/Settings/NotificationsTab.tsx index ae022a0e0..f7c04866a 100644 --- a/client/src/components/Settings/NotificationsTab.tsx +++ b/client/src/components/Settings/NotificationsTab.tsx @@ -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> - channels: ChannelDescriptor[] - event_types: string[] - implemented_combos: Record - /** Cells the admin switched off for everyone (#1536). */ - locked?: Record - defaults?: { ntfyServer: string | null } -} - - export default function NotificationsTab(): React.ReactElement { const { t } = useTranslation() - const toast = useToast() - const [matrix, setMatrix] = useState(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(null) - - useEffect(() => { - notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {}) - settingsApi.get().then((data: { settings: Record }) => { - 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 diff --git a/client/src/components/Settings/useNotificationSettings.test.ts b/client/src/components/Settings/useNotificationSettings.test.ts new file mode 100644 index 000000000..aa6a85440 --- /dev/null +++ b/client/src/components/Settings/useNotificationSettings.test.ts @@ -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 = {}) { + vi.spyOn(notificationsApi, 'getPreferences').mockResolvedValue( + JSON.parse(JSON.stringify(MATRIX)) as PreferencesMatrix + ); + vi.spyOn(settingsApi, 'get').mockResolvedValue({ settings }); +} + +async function loaded(options?: Parameters[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(); + }); +}); diff --git a/client/src/components/Settings/useNotificationSettings.ts b/client/src/components/Settings/useNotificationSettings.ts new file mode 100644 index 000000000..59127519b --- /dev/null +++ b/client/src/components/Settings/useNotificationSettings.ts @@ -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>; + channels: ChannelDescriptor[]; + event_types: string[]; + implemented_combos: Record; + /** Cells the admin switched off for everyone (#1536). */ + locked?: Record; + 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(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(null); + + useEffect(() => { + notificationsApi + .getPreferences() + .then((data: PreferencesMatrix) => setMatrix(data)) + .catch(() => {}); + settingsApi + .get() + .then((data: { settings: Record }) => { + 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>; + 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, + }; +} diff --git a/client/src/mobile/screens/settings/MSettingsNotifications.tsx b/client/src/mobile/screens/settings/MSettingsNotifications.tsx index 628623ffe..b2e8fa1e6 100644 --- a/client/src/mobile/screens/settings/MSettingsNotifications.tsx +++ b/client/src/mobile/screens/settings/MSettingsNotifications.tsx @@ -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> - channels: ChannelDescriptor[] - event_types: string[] - implemented_combos: Record - /** Cells the admin switched off for everyone (#1536). */ - locked?: Record - 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(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(null) - - useEffect(() => { - notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {}) - settingsApi.get().then((data: { settings: Record }) => { - 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 (