From 0438fb652763945c010e0a1ab2f3630c53612af6 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sun, 19 Jul 2026 02:08:09 -0600 Subject: [PATCH] Split notification-settings.tsx into a hook and section components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notification-settings.tsx was 646 lines mixing all notification state and logic with the full render tree. Extracted: - use-notification-settings.ts — all state (15 useState), refs, effects, and handlers (save/test/toggle/permission) as a single logic hook - notification-settings-constants.ts — NotifyEventType, response type, EVENT_OPTIONS - notification-device-sections.tsx — SoundCuesSection + TipsSection (device-only local toggles) - browser-notifications-section.tsx — Browser Notifications region (owns its platform detection) - slack-notifications-section.tsx — Slack webhook + event toggles + save/test actions NotificationSettings is now a 75-line composition shell. Pure structural refactor — DOM, classes, testids, and behavior are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/browser-notifications-section.tsx | 160 ++++ .../app/notification-device-sections.tsx | 108 +++ .../app/notification-settings-constants.ts | 26 + .../components/app/notification-settings.tsx | 683 ++---------------- .../app/slack-notifications-section.tsx | 141 ++++ .../app/use-notification-settings.ts | 302 ++++++++ 6 files changed, 793 insertions(+), 627 deletions(-) create mode 100644 apps/web/src/components/app/browser-notifications-section.tsx create mode 100644 apps/web/src/components/app/notification-device-sections.tsx create mode 100644 apps/web/src/components/app/notification-settings-constants.ts create mode 100644 apps/web/src/components/app/slack-notifications-section.tsx create mode 100644 apps/web/src/components/app/use-notification-settings.ts diff --git a/apps/web/src/components/app/browser-notifications-section.tsx b/apps/web/src/components/app/browser-notifications-section.tsx new file mode 100644 index 000000000..2deaaa551 --- /dev/null +++ b/apps/web/src/components/app/browser-notifications-section.tsx @@ -0,0 +1,160 @@ +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + EVENT_OPTIONS, + type NotifyEventType, +} from "@/components/app/notification-settings-constants"; + +type BrowserNotificationsSectionProps = { + webNotifyEnabled: boolean; + webNotifyEvents: NotifyEventType[]; + browserPermission: NotificationPermission; + saving: boolean; + webError: string; + webMessage: string; + onRequestPermission: () => void; + onToggleWebNotifyEnabled: (checked: boolean) => void; + onToggleWebEvent: (eventType: NotifyEventType) => void; + onTestWebNotification: () => void; +}; + +export function BrowserNotificationsSection({ + webNotifyEnabled, + webNotifyEvents, + browserPermission, + saving, + webError, + webMessage, + onRequestPermission, + onToggleWebNotifyEnabled, + onToggleWebEvent, + onTestWebNotification, +}: BrowserNotificationsSectionProps): JSX.Element { + const notificationsSupported = typeof Notification !== "undefined"; + const isStandalone = + window.matchMedia("(display-mode: standalone)").matches || + ("standalone" in navigator && + (navigator as { standalone?: boolean }).standalone === true); + const isIOS = + /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + + return ( +
+

+ Browser Notifications +

+

+ Get native desktop or mobile notifications when agents need attention. + When enabled and the app is open, browser notifications are used instead + of Slack. +

+ + {!notificationsSupported ? ( +

+ Browser notifications are not supported in this browser. + {isIOS && !isStandalone + ? " On iOS/iPadOS, install Dispatch as a PWA (Add to Home Screen) to enable notifications." + : ""} +

+ ) : ( +
+ {/* Permission status + grant button */} + {browserPermission !== "granted" ? ( +
+
+
+ {browserPermission === "denied" + ? "Notifications blocked" + : "Permission required"} +
+
+ {browserPermission === "denied" + ? isIOS + ? "Open device Settings > Notifications > Dispatch to enable, or tap Allow to re-request" + : "Update the notification permission in your browser settings, or tap Allow to re-request" + : isIOS + ? "Tap Allow, then confirm in the system prompt" + : "Your browser needs permission to show notifications"} +
+
+ +
+ ) : null} + + {/* Enable toggle */} + + + {/* Web event toggles */} + {webNotifyEnabled && browserPermission === "granted" && ( +
+
Notify on:
+ {EVENT_OPTIONS.map(({ id, label, description }) => ( + + ))} +
+ +
+
+ )} + {webError ? ( +

{webError}

+ ) : null} + {webMessage ? ( +

{webMessage}

+ ) : null} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/app/notification-device-sections.tsx b/apps/web/src/components/app/notification-device-sections.tsx new file mode 100644 index 000000000..22c78eda0 --- /dev/null +++ b/apps/web/src/components/app/notification-device-sections.tsx @@ -0,0 +1,108 @@ +import { useAtom, useSetAtom } from "jotai"; +import { Play, RotateCcw } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { soundCuesEnabledAtom } from "@/lib/store"; +import { + dismissedTipsAtom, + lastSeenVersionAtom, + tipsEnabledAtom, +} from "@/lib/tips/tips-state"; +import { CUE_INTENTS, playCueForIntent, playTapCue } from "@/lib/sound-cues"; + +export function SoundCuesSection(): JSX.Element { + const [enabled, setEnabled] = useAtom(soundCuesEnabledAtom); + return ( +
+

+ Sound Cues +

+

+ Soft tones for agent status changes and mobile toolbar taps. This device + only. +

+
+ + {enabled && ( +
+ {CUE_INTENTS.map(({ intent, label }) => ( + + ))} + +
+ )} +
+
+ ); +} + +export function TipsSection(): JSX.Element { + const [enabled, setEnabled] = useAtom(tipsEnabledAtom); + const setDismissed = useSetAtom(dismissedTipsAtom); + const setLastSeenVersion = useSetAtom(lastSeenVersionAtom); + + return ( +
+

+ Tips & Guidance +

+

+ Contextual tips that highlight features and link to docs. This device + only. +

+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/app/notification-settings-constants.ts b/apps/web/src/components/app/notification-settings-constants.ts new file mode 100644 index 000000000..6ab9c74f4 --- /dev/null +++ b/apps/web/src/components/app/notification-settings-constants.ts @@ -0,0 +1,26 @@ +export type NotifyEventType = "done" | "waiting_user" | "blocked"; + +export type NotificationSettingsResponse = { + webhookUrl: string; + notifyEvents: NotifyEventType[]; + webNotifyEnabled: boolean; + webNotifyEvents: NotifyEventType[]; +}; + +export const EVENT_OPTIONS: Array<{ + id: NotifyEventType; + label: string; + description: string; +}> = [ + { id: "done", label: "Done", description: "Agent finished its task" }, + { + id: "waiting_user", + label: "Waiting for input", + description: "Agent needs your response", + }, + { + id: "blocked", + label: "Blocked", + description: "Agent hit an error or obstacle", + }, +]; diff --git a/apps/web/src/components/app/notification-settings.tsx b/apps/web/src/components/app/notification-settings.tsx index 6687425e1..70674b045 100644 --- a/apps/web/src/components/app/notification-settings.tsx +++ b/apps/web/src/components/app/notification-settings.tsx @@ -1,417 +1,40 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useAtom, useSetAtom } from "jotai"; -import { Play, RotateCcw } from "lucide-react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; -import { api } from "@/lib/api"; -import { soundCuesEnabledAtom } from "@/lib/store"; +import { BrowserNotificationsSection } from "@/components/app/browser-notifications-section"; import { - dismissedTipsAtom, - lastSeenVersionAtom, - tipsEnabledAtom, -} from "@/lib/tips/tips-state"; -import { CUE_INTENTS, playCueForIntent, playTapCue } from "@/lib/sound-cues"; -import { - getNotificationPermission, - requestNotificationPermission, -} from "@/lib/web-notifications"; - -type NotifyEventType = "done" | "waiting_user" | "blocked"; - -type NotificationSettingsResponse = { - webhookUrl: string; - notifyEvents: NotifyEventType[]; - webNotifyEnabled: boolean; - webNotifyEvents: NotifyEventType[]; -}; - -const EVENT_OPTIONS: Array<{ - id: NotifyEventType; - label: string; - description: string; -}> = [ - { id: "done", label: "Done", description: "Agent finished its task" }, - { - id: "waiting_user", - label: "Waiting for input", - description: "Agent needs your response", - }, - { - id: "blocked", - label: "Blocked", - description: "Agent hit an error or obstacle", - }, -]; - -function SoundCuesSection(): JSX.Element { - const [enabled, setEnabled] = useAtom(soundCuesEnabledAtom); - return ( -
-

- Sound Cues -

-

- Soft tones for agent status changes and mobile toolbar taps. This device - only. -

-
- - {enabled && ( -
- {CUE_INTENTS.map(({ intent, label }) => ( - - ))} - -
- )} -
-
- ); -} - -function TipsSection(): JSX.Element { - const [enabled, setEnabled] = useAtom(tipsEnabledAtom); - const setDismissed = useSetAtom(dismissedTipsAtom); - const setLastSeenVersion = useSetAtom(lastSeenVersionAtom); - - return ( -
-

- Tips & Guidance -

-

- Contextual tips that highlight features and link to docs. This device - only. -

-
- - -
-
- ); -} + SoundCuesSection, + TipsSection, +} from "@/components/app/notification-device-sections"; +import { SlackNotificationsSection } from "@/components/app/slack-notifications-section"; +import { useNotificationSettings } from "@/components/app/use-notification-settings"; export function NotificationSettings(): JSX.Element { - // Slack settings - const [webhookUrl, setWebhookUrl] = useState(""); - const [savedUrl, setSavedUrl] = useState(""); - const [notifyEvents, setNotifyEvents] = useState([ - "done", - "waiting_user", - "blocked", - ]); - const [savedEvents, setSavedEvents] = useState([]); - - // Web notification settings - const [webNotifyEnabled, setWebNotifyEnabled] = useState(false); - const [savedWebEnabled, setSavedWebEnabled] = useState(false); - const [webNotifyEvents, setWebNotifyEvents] = useState([ - "done", - "waiting_user", - "blocked", - ]); - const [savedWebEvents, setSavedWebEvents] = useState([]); - const [browserPermission, setBrowserPermission] = - useState(getNotificationPermission()); - - // Re-check permission when the user returns to this page (e.g. after - // changing settings in iOS Settings or browser site settings). - useEffect(() => { - const onVisibilityChange = () => { - if (!document.hidden) { - setBrowserPermission(getNotificationPermission()); - } - }; - document.addEventListener("visibilitychange", onVisibilityChange); - return () => - document.removeEventListener("visibilitychange", onVisibilityChange); - }, []); - - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [message, setMessage] = useState(""); - const [error, setError] = useState(""); - const [webMessage, setWebMessage] = useState(""); - const [webError, setWebError] = useState(""); - const webNotifyEnabledRef = useRef(webNotifyEnabled); - const webNotifyEventsRef = useRef(webNotifyEvents); - const savedWebEnabledRef = useRef(savedWebEnabled); - const savedWebEventsRef = useRef(savedWebEvents); - const webSaveRequestIdRef = useRef(0); - - useEffect(() => { - let cancelled = false; - void (async () => { - try { - const data = await api( - "/api/v1/notifications/settings" - ); - if (cancelled) return; - setWebhookUrl(data.webhookUrl); - setSavedUrl(data.webhookUrl); - setNotifyEvents(data.notifyEvents); - setSavedEvents(data.notifyEvents); - setWebNotifyEnabled(data.webNotifyEnabled); - setSavedWebEnabled(data.webNotifyEnabled); - setWebNotifyEvents(data.webNotifyEvents); - setSavedWebEvents(data.webNotifyEvents); - } catch { - // ignore — first load may fail if server is starting - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - webNotifyEnabledRef.current = webNotifyEnabled; - webNotifyEventsRef.current = webNotifyEvents; - savedWebEnabledRef.current = savedWebEnabled; - savedWebEventsRef.current = savedWebEvents; - }, [savedWebEnabled, savedWebEvents, webNotifyEnabled, webNotifyEvents]); - - const hasChanges = - webhookUrl !== savedUrl || - JSON.stringify([...notifyEvents].sort()) !== - JSON.stringify([...savedEvents].sort()) || - webNotifyEnabled !== savedWebEnabled || - JSON.stringify([...webNotifyEvents].sort()) !== - JSON.stringify([...savedWebEvents].sort()); - - const handleSave = useCallback(async () => { - setError(""); - setMessage(""); - setSaving(true); - try { - const data = await api( - "/api/v1/notifications/settings", - { - method: "POST", - body: JSON.stringify({ - webhookUrl, - notifyEvents, - webNotifyEnabled, - webNotifyEvents, - }), - } - ); - setSavedUrl(data.webhookUrl); - setSavedEvents(data.notifyEvents); - setWebhookUrl(data.webhookUrl); - setNotifyEvents(data.notifyEvents); - setSavedWebEnabled(data.webNotifyEnabled); - setSavedWebEvents(data.webNotifyEvents); - setWebNotifyEnabled(data.webNotifyEnabled); - setWebNotifyEvents(data.webNotifyEvents); - setMessage("Settings saved."); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save."); - } finally { - setSaving(false); - } - }, [webhookUrl, notifyEvents, webNotifyEnabled, webNotifyEvents]); - - const persistWebNotificationSettings = useCallback( - async ( - nextEnabled: boolean, - nextEvents: NotifyEventType[] - ): Promise => { - const requestId = webSaveRequestIdRef.current + 1; - webSaveRequestIdRef.current = requestId; - setWebError(""); - setWebMessage(""); - setSaving(true); - try { - const data = await api( - "/api/v1/notifications/settings", - { - method: "POST", - body: JSON.stringify({ - webNotifyEnabled: nextEnabled, - webNotifyEvents: nextEvents, - }), - } - ); - if (webSaveRequestIdRef.current !== requestId) { - return true; - } - setSavedWebEnabled(data.webNotifyEnabled); - setSavedWebEvents(data.webNotifyEvents); - setWebNotifyEnabled(data.webNotifyEnabled); - setWebNotifyEvents(data.webNotifyEvents); - setWebMessage("Browser notification settings saved."); - return true; - } catch (err) { - if (webSaveRequestIdRef.current !== requestId) { - return true; - } - setWebError( - err instanceof Error - ? err.message - : "Failed to save browser notifications." - ); - return false; - } finally { - if (webSaveRequestIdRef.current === requestId) { - setSaving(false); - } - } - }, - [] - ); - - const handleTest = useCallback(async () => { - setError(""); - setMessage(""); - setTesting(true); - try { - const result = await api<{ ok: boolean; error?: string }>( - "/api/v1/notifications/test", - { - method: "POST", - body: JSON.stringify({ webhookUrl: webhookUrl || undefined }), - } - ); - if (result.ok) { - setMessage("Test message sent — check your Slack channel!"); - } else { - setError(result.error ?? "Test failed."); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to send test."); - } finally { - setTesting(false); - } - }, [webhookUrl]); - - const toggleEvent = useCallback((eventType: NotifyEventType) => { - setNotifyEvents((prev) => - prev.includes(eventType) - ? prev.filter((e) => e !== eventType) - : [...prev, eventType] - ); - }, []); - - const toggleWebEvent = useCallback( - async (eventType: NotifyEventType) => { - const previousEnabled = webNotifyEnabledRef.current; - const previousEvents = webNotifyEventsRef.current; - const nextEvents = previousEvents.includes(eventType) - ? previousEvents.filter((e) => e !== eventType) - : [...previousEvents, eventType]; - - setWebNotifyEvents(nextEvents); - const saved = await persistWebNotificationSettings( - previousEnabled, - nextEvents - ); - if (!saved) { - setWebNotifyEnabled(savedWebEnabledRef.current); - setWebNotifyEvents(savedWebEventsRef.current); - } - }, - [persistWebNotificationSettings] - ); - - const toggleWebNotifyEnabled = useCallback( - async (checked: boolean) => { - const previousEvents = webNotifyEventsRef.current; - const nextEnabled = checked; - - setWebNotifyEnabled(nextEnabled); - const saved = await persistWebNotificationSettings( - nextEnabled, - previousEvents - ); - if (!saved) { - setWebNotifyEnabled(savedWebEnabledRef.current); - setWebNotifyEvents(savedWebEventsRef.current); - } - }, - [persistWebNotificationSettings] - ); - - const handleRequestPermission = useCallback(async () => { - const result = await requestNotificationPermission(); - setBrowserPermission(result); - }, []); - - const handleTestWebNotification = useCallback(() => { - if (Notification.permission !== "granted") return; - new Notification("Dispatch test notification", { - body: "Browser notifications are working!", - tag: "dispatch-test", - }); - setMessage("Test notification sent — check your browser!"); - }, []); + const { + loading, + webhookUrl, + handleWebhookUrlChange, + notifyEvents, + toggleEvent, + webNotifyEnabled, + webNotifyEvents, + browserPermission, + toggleWebEvent, + toggleWebNotifyEnabled, + handleRequestPermission, + handleTestWebNotification, + saving, + testing, + message, + error, + webMessage, + webError, + hasChanges, + handleSave, + handleTest, + } = useNotificationSettings(); if (loading) { return
Loading...
; } - const notificationsSupported = typeof Notification !== "undefined"; - const isStandalone = - window.matchMedia("(display-mode: standalone)").matches || - ("standalone" in navigator && - (navigator as { standalone?: boolean }).standalone === true); - const isIOS = - /iPad|iPhone|iPod/.test(navigator.userAgent) || - (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); - return (
@@ -420,227 +43,33 @@ export function NotificationSettings(): JSX.Element {
- {/* Browser Notifications */} -
-

- Browser Notifications -

-

- Get native desktop or mobile notifications when agents need attention. - When enabled and the app is open, browser notifications are used - instead of Slack. -

- - {!notificationsSupported ? ( -

- Browser notifications are not supported in this browser. - {isIOS && !isStandalone - ? " On iOS/iPadOS, install Dispatch as a PWA (Add to Home Screen) to enable notifications." - : ""} -

- ) : ( -
- {/* Permission status + grant button */} - {browserPermission !== "granted" ? ( -
-
-
- {browserPermission === "denied" - ? "Notifications blocked" - : "Permission required"} -
-
- {browserPermission === "denied" - ? isIOS - ? "Open device Settings > Notifications > Dispatch to enable, or tap Allow to re-request" - : "Update the notification permission in your browser settings, or tap Allow to re-request" - : isIOS - ? "Tap Allow, then confirm in the system prompt" - : "Your browser needs permission to show notifications"} -
-
- -
- ) : null} - - {/* Enable toggle */} - - - {/* Web event toggles */} - {webNotifyEnabled && browserPermission === "granted" && ( -
-
Notify on:
- {EVENT_OPTIONS.map(({ id, label, description }) => ( - - ))} -
- -
-
- )} - {webError ? ( -

{webError}

- ) : null} - {webMessage ? ( -

{webMessage}

- ) : null} -
- )} -
- - {/* Slack Webhook */} -
-

- Slack Webhook -

-

- Receive notifications in Slack when agents finish, need input, or get - blocked. - {webNotifyEnabled && ( - <> - {" "} - When browser notifications are active, Slack is used as a fallback - for when the app is closed. - - )} - {!webNotifyEnabled && ( - <> - {" "} - Create an{" "} - - Incoming Webhook - {" "} - in your Slack workspace and paste the URL below. - - )} -

-
- { - setWebhookUrl(e.target.value); - setMessage(""); - setError(""); - }} - data-testid="slack-webhook-url" - /> -
-
- - {/* Slack Event toggles */} -
-

- Slack notify on -

-

- Choose which agent status changes trigger a Slack notification. -

-
- {EVENT_OPTIONS.map(({ id, label, description }) => ( - - ))} -
-
- - {/* Actions */} -
- {error &&

{error}

} - {message && ( -

{message}

- )} -
- - -
-
+ + + ); } diff --git a/apps/web/src/components/app/slack-notifications-section.tsx b/apps/web/src/components/app/slack-notifications-section.tsx new file mode 100644 index 000000000..7b591706c --- /dev/null +++ b/apps/web/src/components/app/slack-notifications-section.tsx @@ -0,0 +1,141 @@ +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { + EVENT_OPTIONS, + type NotifyEventType, +} from "@/components/app/notification-settings-constants"; + +type SlackNotificationsSectionProps = { + webhookUrl: string; + webNotifyEnabled: boolean; + notifyEvents: NotifyEventType[]; + saving: boolean; + testing: boolean; + hasChanges: boolean; + message: string; + error: string; + onWebhookUrlChange: (value: string) => void; + onToggleEvent: (eventType: NotifyEventType) => void; + onSave: () => void; + onTest: () => void; +}; + +export function SlackNotificationsSection({ + webhookUrl, + webNotifyEnabled, + notifyEvents, + saving, + testing, + hasChanges, + message, + error, + onWebhookUrlChange, + onToggleEvent, + onSave, + onTest, +}: SlackNotificationsSectionProps): JSX.Element { + return ( + <> + {/* Slack Webhook */} +
+

+ Slack Webhook +

+

+ Receive notifications in Slack when agents finish, need input, or get + blocked. + {webNotifyEnabled && ( + <> + {" "} + When browser notifications are active, Slack is used as a fallback + for when the app is closed. + + )} + {!webNotifyEnabled && ( + <> + {" "} + Create an{" "} + + Incoming Webhook + {" "} + in your Slack workspace and paste the URL below. + + )} +

+
+ onWebhookUrlChange(e.target.value)} + data-testid="slack-webhook-url" + /> +
+
+ + {/* Slack Event toggles */} +
+

+ Slack notify on +

+

+ Choose which agent status changes trigger a Slack notification. +

+
+ {EVENT_OPTIONS.map(({ id, label, description }) => ( + + ))} +
+
+ + {/* Actions */} +
+ {error &&

{error}

} + {message && ( +

{message}

+ )} +
+ + +
+
+ + ); +} diff --git a/apps/web/src/components/app/use-notification-settings.ts b/apps/web/src/components/app/use-notification-settings.ts new file mode 100644 index 000000000..0aeb0d862 --- /dev/null +++ b/apps/web/src/components/app/use-notification-settings.ts @@ -0,0 +1,302 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "@/lib/api"; +import { + getNotificationPermission, + requestNotificationPermission, +} from "@/lib/web-notifications"; +import type { + NotificationSettingsResponse, + NotifyEventType, +} from "@/components/app/notification-settings-constants"; + +export function useNotificationSettings() { + // Slack settings + const [webhookUrl, setWebhookUrl] = useState(""); + const [savedUrl, setSavedUrl] = useState(""); + const [notifyEvents, setNotifyEvents] = useState([ + "done", + "waiting_user", + "blocked", + ]); + const [savedEvents, setSavedEvents] = useState([]); + + // Web notification settings + const [webNotifyEnabled, setWebNotifyEnabled] = useState(false); + const [savedWebEnabled, setSavedWebEnabled] = useState(false); + const [webNotifyEvents, setWebNotifyEvents] = useState([ + "done", + "waiting_user", + "blocked", + ]); + const [savedWebEvents, setSavedWebEvents] = useState([]); + const [browserPermission, setBrowserPermission] = + useState(getNotificationPermission()); + + // Re-check permission when the user returns to this page (e.g. after + // changing settings in iOS Settings or browser site settings). + useEffect(() => { + const onVisibilityChange = () => { + if (!document.hidden) { + setBrowserPermission(getNotificationPermission()); + } + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => + document.removeEventListener("visibilitychange", onVisibilityChange); + }, []); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [webMessage, setWebMessage] = useState(""); + const [webError, setWebError] = useState(""); + const webNotifyEnabledRef = useRef(webNotifyEnabled); + const webNotifyEventsRef = useRef(webNotifyEvents); + const savedWebEnabledRef = useRef(savedWebEnabled); + const savedWebEventsRef = useRef(savedWebEvents); + const webSaveRequestIdRef = useRef(0); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const data = await api( + "/api/v1/notifications/settings" + ); + if (cancelled) return; + setWebhookUrl(data.webhookUrl); + setSavedUrl(data.webhookUrl); + setNotifyEvents(data.notifyEvents); + setSavedEvents(data.notifyEvents); + setWebNotifyEnabled(data.webNotifyEnabled); + setSavedWebEnabled(data.webNotifyEnabled); + setWebNotifyEvents(data.webNotifyEvents); + setSavedWebEvents(data.webNotifyEvents); + } catch { + // ignore — first load may fail if server is starting + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + webNotifyEnabledRef.current = webNotifyEnabled; + webNotifyEventsRef.current = webNotifyEvents; + savedWebEnabledRef.current = savedWebEnabled; + savedWebEventsRef.current = savedWebEvents; + }, [savedWebEnabled, savedWebEvents, webNotifyEnabled, webNotifyEvents]); + + const hasChanges = + webhookUrl !== savedUrl || + JSON.stringify([...notifyEvents].sort()) !== + JSON.stringify([...savedEvents].sort()) || + webNotifyEnabled !== savedWebEnabled || + JSON.stringify([...webNotifyEvents].sort()) !== + JSON.stringify([...savedWebEvents].sort()); + + const handleSave = useCallback(async () => { + setError(""); + setMessage(""); + setSaving(true); + try { + const data = await api( + "/api/v1/notifications/settings", + { + method: "POST", + body: JSON.stringify({ + webhookUrl, + notifyEvents, + webNotifyEnabled, + webNotifyEvents, + }), + } + ); + setSavedUrl(data.webhookUrl); + setSavedEvents(data.notifyEvents); + setWebhookUrl(data.webhookUrl); + setNotifyEvents(data.notifyEvents); + setSavedWebEnabled(data.webNotifyEnabled); + setSavedWebEvents(data.webNotifyEvents); + setWebNotifyEnabled(data.webNotifyEnabled); + setWebNotifyEvents(data.webNotifyEvents); + setMessage("Settings saved."); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save."); + } finally { + setSaving(false); + } + }, [webhookUrl, notifyEvents, webNotifyEnabled, webNotifyEvents]); + + const persistWebNotificationSettings = useCallback( + async ( + nextEnabled: boolean, + nextEvents: NotifyEventType[] + ): Promise => { + const requestId = webSaveRequestIdRef.current + 1; + webSaveRequestIdRef.current = requestId; + setWebError(""); + setWebMessage(""); + setSaving(true); + try { + const data = await api( + "/api/v1/notifications/settings", + { + method: "POST", + body: JSON.stringify({ + webNotifyEnabled: nextEnabled, + webNotifyEvents: nextEvents, + }), + } + ); + if (webSaveRequestIdRef.current !== requestId) { + return true; + } + setSavedWebEnabled(data.webNotifyEnabled); + setSavedWebEvents(data.webNotifyEvents); + setWebNotifyEnabled(data.webNotifyEnabled); + setWebNotifyEvents(data.webNotifyEvents); + setWebMessage("Browser notification settings saved."); + return true; + } catch (err) { + if (webSaveRequestIdRef.current !== requestId) { + return true; + } + setWebError( + err instanceof Error + ? err.message + : "Failed to save browser notifications." + ); + return false; + } finally { + if (webSaveRequestIdRef.current === requestId) { + setSaving(false); + } + } + }, + [] + ); + + const handleTest = useCallback(async () => { + setError(""); + setMessage(""); + setTesting(true); + try { + const result = await api<{ ok: boolean; error?: string }>( + "/api/v1/notifications/test", + { + method: "POST", + body: JSON.stringify({ webhookUrl: webhookUrl || undefined }), + } + ); + if (result.ok) { + setMessage("Test message sent — check your Slack channel!"); + } else { + setError(result.error ?? "Test failed."); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to send test."); + } finally { + setTesting(false); + } + }, [webhookUrl]); + + const handleWebhookUrlChange = useCallback((value: string) => { + setWebhookUrl(value); + setMessage(""); + setError(""); + }, []); + + const toggleEvent = useCallback((eventType: NotifyEventType) => { + setNotifyEvents((prev) => + prev.includes(eventType) + ? prev.filter((e) => e !== eventType) + : [...prev, eventType] + ); + }, []); + + const toggleWebEvent = useCallback( + async (eventType: NotifyEventType) => { + const previousEnabled = webNotifyEnabledRef.current; + const previousEvents = webNotifyEventsRef.current; + const nextEvents = previousEvents.includes(eventType) + ? previousEvents.filter((e) => e !== eventType) + : [...previousEvents, eventType]; + + setWebNotifyEvents(nextEvents); + const saved = await persistWebNotificationSettings( + previousEnabled, + nextEvents + ); + if (!saved) { + setWebNotifyEnabled(savedWebEnabledRef.current); + setWebNotifyEvents(savedWebEventsRef.current); + } + }, + [persistWebNotificationSettings] + ); + + const toggleWebNotifyEnabled = useCallback( + async (checked: boolean) => { + const previousEvents = webNotifyEventsRef.current; + const nextEnabled = checked; + + setWebNotifyEnabled(nextEnabled); + const saved = await persistWebNotificationSettings( + nextEnabled, + previousEvents + ); + if (!saved) { + setWebNotifyEnabled(savedWebEnabledRef.current); + setWebNotifyEvents(savedWebEventsRef.current); + } + }, + [persistWebNotificationSettings] + ); + + const handleRequestPermission = useCallback(async () => { + const result = await requestNotificationPermission(); + setBrowserPermission(result); + }, []); + + const handleTestWebNotification = useCallback(() => { + if (Notification.permission !== "granted") return; + new Notification("Dispatch test notification", { + body: "Browser notifications are working!", + tag: "dispatch-test", + }); + setMessage("Test notification sent — check your browser!"); + }, []); + + return { + loading, + // Slack + webhookUrl, + handleWebhookUrlChange, + notifyEvents, + toggleEvent, + // Web/browser notifications + webNotifyEnabled, + webNotifyEvents, + browserPermission, + toggleWebEvent, + toggleWebNotifyEnabled, + handleRequestPermission, + handleTestWebNotification, + // Shared status + saving, + testing, + message, + error, + webMessage, + webError, + hasChanges, + handleSave, + handleTest, + }; +}