From d042a18d0aa224ed195a900ad0e368867b9d21ad Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:05:31 +0200 Subject: [PATCH] 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. --- .../components/Settings/NotificationsTab.tsx | 200 +------------ .../Settings/useNotificationSettings.test.ts | 194 ++++++++++++ .../Settings/useNotificationSettings.ts | 275 ++++++++++++++++++ .../settings/MSettingsNotifications.tsx | 191 +----------- 4 files changed, 484 insertions(+), 376 deletions(-) create mode 100644 client/src/components/Settings/useNotificationSettings.test.ts create mode 100644 client/src/components/Settings/useNotificationSettings.ts 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 (