diff --git a/apps/web/components/settings/notifications-settings-actions.test.tsx b/apps/web/components/settings/notifications-settings-actions.test.tsx index 4dc897dcee..0cf8a16bb3 100644 --- a/apps/web/components/settings/notifications-settings-actions.test.tsx +++ b/apps/web/components/settings/notifications-settings-actions.test.tsx @@ -24,6 +24,8 @@ const mocks = vi.hoisted(() => ({ })); const PAGER_URL = "json://pager"; const CLARIFICATION_EVENT = "session.clarification_requested"; +const SAVED_EVENT = "task.completed"; +const DRAFT_NAME = "Draft name"; const SEMANTIC_NOTIFICATION_EVENTS = [CLARIFICATION_EVENT, "session.turn_finished"]; vi.mock("@/lib/api", () => ({ @@ -39,21 +41,29 @@ const savedProvider: NotificationProvider = { type: "apprise", config: { urls: ["json://saved"] }, enabled: true, - events: ["task.completed"], + events: [SAVED_EVENT], created_at: "", updated_at: "", }; +const localProvider: NotificationProvider = { + ...savedProvider, + id: "provider-local", + name: "Local provider", + type: "local", + config: {}, +}; let notificationProviders = [savedProvider]; -let notificationEvents = ["task.completed"]; +let notificationEvents = [SAVED_EVENT]; let notificationProvidersLoaded = true; +let notificationAppriseAvailable = true; const originalNotification = globalThis.Notification; vi.mock("@/hooks/domains/settings/use-notification-providers", () => ({ useNotificationProviders: () => ({ providers: notificationProviders, events: notificationEvents, - appriseAvailable: true, + appriseAvailable: notificationAppriseAvailable, loaded: notificationProvidersLoaded, }), })); @@ -90,8 +100,9 @@ const createdProvider: NotificationProvider = { beforeEach(() => { vi.clearAllMocks(); notificationProviders = [savedProvider]; - notificationEvents = ["task.completed"]; + notificationEvents = [SAVED_EVENT]; notificationProvidersLoaded = true; + notificationAppriseAvailable = true; mocks.nativeAvailable.mockReturnValue(true); mocks.createNotificationProvider.mockResolvedValue(createdProvider); }); @@ -152,19 +163,19 @@ describe("notification provider draft hydration", () => { it("does not replace an edited draft when providers refresh", () => { const { result, rerender } = renderHook(useHarness); - act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, "Draft name")); + act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME)); const refreshedProvider = { ...savedProvider, name: "Server refresh" }; notificationProviders = [refreshedProvider]; rerender(); - expect(result.current.state.providers[0]?.name).toBe("Draft name"); + expect(result.current.state.providers[0]?.name).toBe(DRAFT_NAME); expect(result.current.state.baselineProviders[0]?.name).toBe(savedProvider.name); }); it("hydrates a deferred provider refresh after discarding the draft", () => { const { result, rerender } = renderHook(useHarness); - act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, "Draft name")); + act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME)); const refreshedProvider = { ...savedProvider, name: "Server refresh" }; notificationProviders = [refreshedProvider]; rerender(); @@ -174,6 +185,37 @@ describe("notification provider draft hydration", () => { expect(result.current.state.providers).toEqual([refreshedProvider]); expect(result.current.state.baselineProviders).toEqual([refreshedProvider]); }); + + it("updates availability without replacing dirty drafts or pending removals", () => { + notificationProviders = [savedProvider, localProvider]; + const { result, rerender } = renderHook(useHarness); + + act(() => { + result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME); + result.current.actions.handleAppriseEdit(savedProvider.id, PAGER_URL); + result.current.actions.handleToggleEvent(savedProvider, CLARIFICATION_EVENT); + result.current.actions.handleDeleteProvider(localProvider.id); + result.current.actions.openAppriseForm("create"); + result.current.state.setAppriseName("New provider"); + result.current.state.setAppriseUrls("json://new"); + }); + + notificationAppriseAvailable = false; + rerender(); + + expect(result.current.state.appriseAvailable).toBe(false); + expect(result.current.state.providers).toEqual([ + expect.objectContaining({ + id: savedProvider.id, + name: DRAFT_NAME, + config: { urls: [PAGER_URL] }, + events: [SAVED_EVENT, CLARIFICATION_EVENT], + }), + ]); + expect(result.current.state.pendingDeletes).toEqual(new Set([localProvider.id])); + expect(result.current.state.appriseName).toBe("New provider"); + expect(result.current.state.appriseUrls).toBe("json://new"); + }); }); describe("notification provider draft saving", () => { @@ -246,6 +288,38 @@ describe("notification provider draft saving", () => { expect(result.current.state.providers).toEqual([savedProvider]); expect(result.current.isDirty).toBe(false); }); + + it("does not overwrite a rescan that completes during a provider save", async () => { + let resolveUpdate: (provider: NotificationProvider) => void = () => undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdate = resolve; + }); + mocks.updateNotificationProvider.mockReturnValueOnce(updatePromise); + const { result, rerender } = renderHook(useHarness); + + act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME)); + let savePromise: Promise; + act(() => { + savePromise = result.current.saveRequest.run(); + }); + await waitFor(() => expect(mocks.updateNotificationProvider).toHaveBeenCalledOnce()); + + notificationAppriseAvailable = false; + rerender(); + + await act(async () => { + resolveUpdate({ ...savedProvider, name: DRAFT_NAME }); + await savePromise; + }); + + const lastSnapshot = mocks.setNotificationProviders.mock.lastCall?.[0]; + expect(lastSnapshot).toEqual({ + items: [{ ...savedProvider, name: DRAFT_NAME }], + events: [SAVED_EVENT], + loaded: true, + loading: false, + }); + }); }); describe("notification permission actions", () => { diff --git a/apps/web/components/settings/notifications-settings-actions.ts b/apps/web/components/settings/notifications-settings-actions.ts index e5325f3949..c384a00db3 100644 --- a/apps/web/components/settings/notifications-settings-actions.ts +++ b/apps/web/components/settings/notifications-settings-actions.ts @@ -143,20 +143,80 @@ function isSameHydrationSource( ); } +type NotificationDraftHydrationOptions = { + loaded: boolean; + source: NotificationHydrationSource; + draft: NotificationDraft; + setProviders: (value: NotificationProvider[]) => void; + setBaselineProviders: (value: NotificationProvider[]) => void; + setNotificationEvents: (value: string[]) => void; + setAppriseEdits: (value: Record) => void; + setAppriseNameEdits: (value: Record) => void; +}; + +function useHydrateNotificationDraft({ + loaded, + source, + draft, + setProviders, + setBaselineProviders, + setNotificationEvents, + setAppriseEdits, + setAppriseNameEdits, +}: NotificationDraftHydrationOptions) { + const hydratedSource = useRef(undefined); + + useEffect(() => { + if (!loaded || isSameHydrationSource(hydratedSource.current, source)) return; + if (hydratedSource.current && hasDraftChanges(draft)) return; + + hydratedSource.current = source; + const nextProviders = source.providers ?? []; + setProviders(nextProviders); + setBaselineProviders(nextProviders); + setNotificationEvents(source.events ?? []); + const edits = buildAppriseEdits(nextProviders); + setAppriseEdits(edits.urls); + setAppriseNameEdits(edits.names); + }, [ + draft.appriseEdits, + draft.appriseFormMode, + draft.appriseNameEdits, + draft.baselineProviders, + draft.pendingDeletes, + draft.providers, + draft.showAppriseForm, + loaded, + setAppriseEdits, + setAppriseNameEdits, + setBaselineProviders, + setNotificationEvents, + setProviders, + source.appriseAvailable, + source.events, + source.providers, + ]); +} + export function useNotificationsState() { const { providers: storeProviders, events: storeEvents, appriseAvailable: storeAppriseAvailable, loaded, + loading, + rescanApprise, + appriseRescanPending, + appriseRescanError, + appriseRescanResult, } = useNotificationProviders(); + const appriseAvailable = storeAppriseAvailable ?? false; const setNotificationProviders = useAppStore((state) => state.setNotificationProviders); const [providers, setProviders] = useState(() => storeProviders ?? []); const [baselineProviders, setBaselineProviders] = useState( () => storeProviders ?? [], ); const [notificationEvents, setNotificationEvents] = useState(() => storeEvents ?? []); - const [appriseAvailable, setAppriseAvailable] = useState(() => storeAppriseAvailable ?? true); const [appriseName, setAppriseName] = useState(""); const [appriseUrls, setAppriseUrls] = useState(""); const [appriseEdits, setAppriseEdits] = useState>( @@ -169,19 +229,14 @@ export function useNotificationsState() { const [appriseFormMode, setAppriseFormMode] = useState("create"); const [activeAppriseId, setActiveAppriseId] = useState(null); const [pendingDeletes, setPendingDeletes] = useState>(new Set()); - const hydratedSource = useRef(undefined); - - useEffect(() => { - if (!loaded) return; - const source = { + useHydrateNotificationDraft({ + loaded, + source: { providers: storeProviders, events: storeEvents, appriseAvailable: storeAppriseAvailable, - }; - if (isSameHydrationSource(hydratedSource.current, source)) { - return; - } - const draft = { + }, + draft: { providers, baselineProviders, appriseEdits, @@ -189,32 +244,13 @@ export function useNotificationsState() { pendingDeletes, showAppriseForm, appriseFormMode, - }; - if (hydratedSource.current && hasDraftChanges(draft)) { - return; - } - hydratedSource.current = source; - const nextProviders = storeProviders ?? []; - setProviders(nextProviders); - setBaselineProviders(nextProviders); - setNotificationEvents(storeEvents ?? []); - setAppriseAvailable(storeAppriseAvailable ?? true); - const edits = buildAppriseEdits(nextProviders); - setAppriseEdits(edits.urls); - setAppriseNameEdits(edits.names); - }, [ - appriseEdits, - appriseFormMode, - appriseNameEdits, - baselineProviders, - loaded, - pendingDeletes, - providers, - showAppriseForm, - storeAppriseAvailable, - storeEvents, - storeProviders, - ]); + }, + setProviders, + setBaselineProviders, + setNotificationEvents, + setAppriseEdits, + setAppriseNameEdits, + }); return { providers, setProviders, @@ -222,8 +258,13 @@ export function useNotificationsState() { setBaselineProviders, notificationEvents, setNotificationEvents, + loaded, + loading, appriseAvailable, - setAppriseAvailable, + rescanApprise, + appriseRescanPending, + appriseRescanError, + appriseRescanResult, appriseName, setAppriseName, appriseUrls, @@ -304,7 +345,6 @@ export function useSaveRequest(state: NotificationsState) { setNotificationProviders({ items: nextProviders, events: state.notificationEvents, - appriseAvailable: state.appriseAvailable, loaded: true, loading: false, }); diff --git a/apps/web/components/settings/notifications-settings-external-providers.tsx b/apps/web/components/settings/notifications-settings-external-providers.tsx new file mode 100644 index 0000000000..7e51bd8d20 --- /dev/null +++ b/apps/web/components/settings/notifications-settings-external-providers.tsx @@ -0,0 +1,430 @@ +"use client"; + +import { type FormEvent } from "react"; +import { IconBell, IconRefresh } from "@tabler/icons-react"; +import { Trans, useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Textarea } from "@kandev/ui/textarea"; +import { SettingsTarget } from "@/components/settings/settings-target"; +import { type AppriseFormMode } from "@/components/settings/notifications-settings-actions"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import type { NotificationProvider } from "@/lib/types/http"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; + +function AppriseProviderCardActions({ + provider, + onOpenForm, + onDeleteProvider, + onTestProvider, +}: { + provider: NotificationProvider; + onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void; + onDeleteProvider: (providerId: string) => void; + onTestProvider: (providerId: string) => Promise; +}) { + const { t } = useTranslation(); + return ( +
+ + + + + + {t("settings:sendTestNotification")} + + + + +
+ ); +} + +function AppriseProviderList({ + providers, + baselineProviders, + appriseFormMode, + activeAppriseId, + appriseName, + appriseUrls, + onNameChange, + onUrlsChange, + onAppriseNameEdit, + onAppriseEdit, + onOpenForm, + onCloseForm, + onCancelForm, + onDeleteProvider, + onTestProvider, + onTextareaInput, +}: { + providers: NotificationProvider[]; + baselineProviders: NotificationProvider[]; + appriseFormMode: AppriseFormMode; + activeAppriseId: string | null; + appriseName: string; + appriseUrls: string; + onNameChange: (value: string) => void; + onUrlsChange: (value: string) => void; + onAppriseNameEdit: (providerId: string, value: string) => void; + onAppriseEdit: (providerId: string, value: string) => void; + onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void; + onCloseForm: () => void; + onCancelForm: () => void; + onDeleteProvider: (providerId: string) => void; + onTestProvider: (providerId: string) => Promise; + onTextareaInput: (event: FormEvent) => void; +}) { + return ( + <> + {providers.map((provider) => { + const isEditing = appriseFormMode === "edit" && activeAppriseId === provider.id; + const baseline = baselineProviders.find((candidate) => candidate.id === provider.id); + const nameIsDirty = isEditing && provider.name !== baseline?.name; + const urlsIsDirty = + isEditing && + JSON.stringify(provider.config?.urls ?? []) !== + JSON.stringify(baseline?.config?.urls ?? []); + return ( +
+ {isEditing ? ( + { + onNameChange(value); + onAppriseNameEdit(provider.id, value); + }} + onUrlsChange={(value) => { + onUrlsChange(value); + onAppriseEdit(provider.id, value); + }} + onSubmit={onCloseForm} + onCancel={onCancelForm} + onInput={onTextareaInput} + nameIsDirty={nameIsDirty} + urlsIsDirty={urlsIsDirty} + /> + ) : ( +
+
+
{provider.name}
+ {/* Product name — never translated (docs/i18n.md "Do not translate"). */} +
Apprise
+
+ +
+ )} +
+ ); + })} + + ); +} + +type ExternalProvidersSectionProps = { + appriseAvailable: boolean; + appriseProviders: NotificationProvider[]; + baselineProviders: NotificationProvider[]; + appriseFormMode: AppriseFormMode; + activeAppriseId: string | null; + appriseName: string; + appriseUrls: string; + showAppriseForm: boolean; + notificationProvidersLoaded: boolean; + notificationProvidersLoading: boolean; + appriseRescanPending: boolean; + appriseRescanError: boolean; + appriseRescanResult: boolean | null; + setAppriseName: (v: string) => void; + setAppriseUrls: (v: string) => void; + onAppriseNameEdit: (id: string, v: string) => void; + onAppriseEdit: (id: string, v: string) => void; + onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void; + onCloseForm: () => void; + onCancelForm: () => void; + onDeleteProvider: (id: string) => void; + onTestProvider: (id: string) => Promise; + onRescanApprise: () => Promise; +}; + +function AppriseRescanControls({ + notificationProvidersLoaded, + notificationProvidersLoading, + appriseRescanPending, + appriseRescanError, + appriseRescanResult, + onRescanApprise, +}: Pick< + ExternalProvidersSectionProps, + | "notificationProvidersLoaded" + | "notificationProvidersLoading" + | "appriseRescanPending" + | "appriseRescanError" + | "appriseRescanResult" + | "onRescanApprise" +>) { + const { t } = useTranslation(); + return ( +
+ +

{t("settings:appriseDetectionServer")}

+ {appriseRescanError && ( +

+ {t("settings:appriseRescanFailed")} +

+ )} + {!appriseRescanPending && !appriseRescanError && appriseRescanResult !== null && ( +

+ {appriseRescanResult ? t("settings:appriseDetected") : t("settings:appriseNotDetected")} +

+ )} +
+ ); +} + +function AppriseProviderSectionBody({ + appriseAvailable, + appriseProviders, + baselineProviders, + appriseFormMode, + activeAppriseId, + appriseName, + appriseUrls, + showAppriseForm, + setAppriseName, + setAppriseUrls, + onAppriseNameEdit, + onAppriseEdit, + onOpenForm, + onCloseForm, + onCancelForm, + onDeleteProvider, + onTestProvider, +}: Omit< + ExternalProvidersSectionProps, + | "notificationProvidersLoaded" + | "notificationProvidersLoading" + | "appriseRescanPending" + | "appriseRescanError" + | "appriseRescanResult" + | "onRescanApprise" +>) { + const { t } = useTranslation(); + return ( + <> + {!appriseAvailable && ( +

+ {/* The link is part of the sentence, so the whole notice is one + message and the <2> tag addresses the anchor by child index. */} + + Apprise is not installed yet. You can add it later to enable remote notifications.{" "} + + View installation instructions + + . + +

+ )} + {appriseProviders.length === 0 && ( +

{t("settings:noAppriseProviders")}

+ )} + +
+ {appriseAvailable && ( + + )} + {showAppriseForm && appriseFormMode === "create" && ( + 0} + urlsIsDirty={appriseUrls.length > 0} + showSubmit={false} + /> + )} +
+ + ); +} + +export function ExternalProvidersSection(props: ExternalProvidersSectionProps) { + const { t } = useTranslation(); + return ( + +
+
+
{t("settings:externalProviders")}
+

+ {t("settings:externalProvidersDescription")} +

+
+ +
+ +
+ ); +} + +type AppriseProviderFormProps = { + mode: AppriseFormMode; + name: string; + urls: string; + onNameChange: (value: string) => void; + onUrlsChange: (value: string) => void; + onSubmit: () => void | Promise; + onCancel: () => void; + onInput: (event: FormEvent) => void; + nameIsDirty?: boolean; + urlsIsDirty?: boolean; + formIsDirty?: boolean; + showSubmit?: boolean; +}; + +function AppriseProviderForm({ + mode, + name, + urls, + onNameChange, + onUrlsChange, + onSubmit, + onCancel, + onInput, + nameIsDirty = false, + urlsIsDirty = false, + formIsDirty = nameIsDirty || urlsIsDirty, + showSubmit = true, +}: AppriseProviderFormProps) { + const { t } = useTranslation(); + return ( +
+
{t("settings:appriseProvider")}
+ onNameChange(event.target.value)} + placeholder={t("settings:providerName")} + data-settings-dirty={nameIsDirty} + /> +