Files
kandev/apps/web/components/settings/notifications-settings.tsx
T

594 lines
20 KiB
TypeScript

"use client";
import { useMemo, useReducer, useSyncExternalStore, type FormEvent } from "react";
import { IconBell, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card";
import { Input } from "@kandev/ui/input";
import { Separator } from "@kandev/ui/separator";
import { Textarea } from "@kandev/ui/textarea";
import { SettingsPageTemplate } from "@/components/settings/settings-page-template";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { DEFAULT_NOTIFICATION_EVENTS, EVENT_LABELS } from "@/lib/notifications/events";
import type { NotificationProvider } from "@/lib/types/http";
import {
useNotificationsState,
useSaveRequest,
useNotificationsActions,
useIsDirty,
type NotificationsState,
type AppriseFormMode,
} from "@/components/settings/notifications-settings-actions";
type DesktopNotificationsSectionProps = {
notificationPermission: NotificationPermission | "unsupported";
onRequestPermission: () => void;
onRefreshPermission: () => void;
onTestNotification: () => void;
};
function DesktopNotificationsSection({
notificationPermission,
onRequestPermission,
onRefreshPermission,
onTestNotification,
}: DesktopNotificationsSectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-base font-medium">Desktop Notifications</div>
<p className="text-sm text-muted-foreground">
Notify this device when an agent needs your input.
</p>
</div>
<div className="flex items-center gap-2">
<Button
title="Enable desktop notifications"
variant="default"
size="sm"
onClick={onRequestPermission}
disabled={
notificationPermission === "granted" || notificationPermission === "unsupported"
}
className={
notificationPermission === "granted"
? "bg-emerald-500 text-white hover:bg-emerald-500"
: undefined
}
>
{notificationPermission === "granted" ? "Enabled" : "Enable"}
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onRefreshPermission}>
<IconRefresh className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh permission status</TooltipContent>
</Tooltip>
</TooltipProvider>
<HoverCard>
<HoverCardTrigger asChild>
<Button
title="Send test notification"
variant="outline"
className="cursor-pointer"
size="icon"
onClick={() => {
void onTestNotification();
}}
>
<IconBell className="h-4 w-4" />
</Button>
</HoverCardTrigger>
<HoverCardContent side="top" className="text-sm">
If you do not see notifications, check your OS settings and allow this browser.
</HoverCardContent>
</HoverCard>
</div>
</div>
{notificationPermission === "denied" && (
<p className="text-sm text-amber-600">
Notifications are blocked in your browser. Enable them in site settings, then click
Refresh.
</p>
)}
{notificationPermission === "unsupported" && (
<p className="text-sm text-amber-600">
This browser does not support desktop notifications.
</p>
)}
</div>
);
}
type NotificationEventsTableProps = {
tableProviders: NotificationProvider[];
tableEvents: string[];
onToggleEvent: (provider: NotificationProvider, eventType: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
};
function NotificationEventsTable({
tableProviders,
tableEvents,
onToggleEvent,
onTestProvider,
}: NotificationEventsTableProps) {
if (tableProviders.length === 0) {
return <p className="text-sm text-muted-foreground">No providers configured yet.</p>;
}
return (
<div className="overflow-auto rounded-lg border border-muted">
<table className="min-w-full text-sm">
<thead className="bg-muted/40">
<tr>
<th className="px-4 py-3 text-left font-medium">Notification type</th>
{tableProviders.map((provider) => (
<th key={provider.id} className="px-4 py-3 text-center font-medium">
<div className="flex items-center justify-center gap-1.5">
<span>{provider.name}</span>
{provider.type !== "local" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 cursor-pointer"
aria-label={`Send test notification for ${provider.name}`}
onClick={() => void onTestProvider(provider.id)}
>
<IconBell className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Send test notification</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{tableEvents.map((eventType) => {
const meta = EVENT_LABELS[eventType] ?? {
title: eventType,
description: "Notify when this event occurs.",
};
return (
<tr key={eventType} className="border-t border-muted">
<td className="px-4 py-3">
<div className="font-medium">{meta.title}</div>
<div className="text-xs text-muted-foreground">{meta.description}</div>
</td>
{tableProviders.map((provider) => (
<td key={provider.id} className="px-4 py-3 text-center">
<div className="flex justify-center">
<Checkbox
checked={(provider.events ?? []).includes(eventType)}
onCheckedChange={() => onToggleEvent(provider, eventType)}
/>
</div>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function AppriseProviderCardActions({
provider,
onOpenForm,
onDeleteProvider,
onTestProvider,
}: {
provider: NotificationProvider;
onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
}) {
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 cursor-pointer"
aria-label={`Send test notification for ${provider.name}`}
onClick={() => void onTestProvider(provider.id)}
>
<IconBell className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Send test notification</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onOpenForm("edit", provider)}
>
Edit
</Button>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onDeleteProvider(provider.id)}
>
Remove
</Button>
</div>
);
}
function AppriseProviderList({
providers,
appriseFormMode,
activeAppriseId,
appriseName,
appriseUrls,
onNameChange,
onUrlsChange,
onAppriseNameEdit,
onAppriseEdit,
onOpenForm,
onCloseForm,
onDeleteProvider,
onTestProvider,
onTextareaInput,
}: {
providers: 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;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
}) {
return (
<>
{providers.map((provider) => {
const isEditing = appriseFormMode === "edit" && activeAppriseId === provider.id;
return (
<div key={provider.id} className="rounded-lg border border-muted p-4 space-y-3">
{isEditing ? (
<AppriseProviderForm
mode="edit"
name={appriseName}
urls={appriseUrls}
onNameChange={(value) => {
onNameChange(value);
onAppriseNameEdit(provider.id, value);
}}
onUrlsChange={(value) => {
onUrlsChange(value);
onAppriseEdit(provider.id, value);
}}
onSubmit={onCloseForm}
onCancel={onCloseForm}
onInput={onTextareaInput}
/>
) : (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="font-medium">{provider.name}</div>
<div className="text-xs text-muted-foreground">Apprise</div>
</div>
<AppriseProviderCardActions
provider={provider}
onOpenForm={onOpenForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
/>
</div>
)}
</div>
);
})}
</>
);
}
function useNotificationPermission() {
const mounted = useSyncExternalStore(
() => () => undefined,
() => true,
() => false,
);
const [, bumpPermission] = useReducer((value) => value + 1, 0);
let notificationPermission: NotificationPermission | "unsupported";
if (!mounted) notificationPermission = "default";
else if (typeof Notification === "undefined") notificationPermission = "unsupported";
else notificationPermission = Notification.permission;
return { notificationPermission, bumpPermission };
}
type ExternalProvidersSectionProps = {
appriseAvailable: boolean;
appriseProviders: NotificationProvider[];
appriseFormMode: AppriseFormMode;
activeAppriseId: string | null;
appriseName: string;
appriseUrls: string;
showAppriseForm: boolean;
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;
onDeleteProvider: (id: string) => void;
onTestProvider: (id: string) => Promise<void>;
onTextareaInput: (e: FormEvent<HTMLTextAreaElement>) => void;
onCreateAppriseProvider: () => Promise<void>;
};
function ExternalProvidersSection({
appriseAvailable,
appriseProviders,
appriseFormMode,
activeAppriseId,
appriseName,
appriseUrls,
showAppriseForm,
setAppriseName,
setAppriseUrls,
onAppriseNameEdit,
onAppriseEdit,
onOpenForm,
onCloseForm,
onDeleteProvider,
onTestProvider,
onTextareaInput,
onCreateAppriseProvider,
}: ExternalProvidersSectionProps) {
return (
<div className="space-y-4">
<div>
<div className="text-base font-medium">External Providers</div>
<p className="text-sm text-muted-foreground">
Configure external providers for remote notifications.
</p>
</div>
{!appriseAvailable && (
<p className="text-sm text-muted-foreground">
Apprise is not installed yet. You can add it later to enable remote notifications.{" "}
<a
className="underline"
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
target="_blank"
rel="noreferrer"
>
View installation instructions
</a>
.
</p>
)}
{appriseProviders.length === 0 && (
<p className="text-sm text-muted-foreground">No Apprise providers configured yet.</p>
)}
<AppriseProviderList
providers={appriseProviders}
appriseFormMode={appriseFormMode}
activeAppriseId={activeAppriseId}
appriseName={appriseName}
appriseUrls={appriseUrls}
onNameChange={setAppriseName}
onUrlsChange={setAppriseUrls}
onAppriseNameEdit={onAppriseNameEdit}
onAppriseEdit={onAppriseEdit}
onOpenForm={onOpenForm}
onCloseForm={onCloseForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
onTextareaInput={onTextareaInput}
/>
{appriseAvailable && (
<div className="space-y-3">
<Button variant="outline" className="cursor-pointer" onClick={() => onOpenForm("create")}>
Add Apprise Provider
</Button>
{showAppriseForm && appriseFormMode === "create" && (
<AppriseProviderForm
mode="create"
name={appriseName}
urls={appriseUrls}
onNameChange={setAppriseName}
onUrlsChange={setAppriseUrls}
onSubmit={async () => {
await onCreateAppriseProvider();
onCloseForm();
}}
onCancel={onCloseForm}
onInput={onTextareaInput}
/>
)}
</div>
)}
</div>
);
}
function useTableData(state: NotificationsState) {
const { providers, notificationEvents } = state;
const tableProviders = useMemo(
() =>
[...providers].sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
if (a.type === "local") return -1;
if (b.type === "local") return 1;
return a.type.localeCompare(b.type);
}),
[providers],
);
const tableEvents = useMemo(() => {
if (notificationEvents.length > 0) return notificationEvents;
const eventSet = new Set<string>();
for (const provider of providers) {
for (const event of provider.events ?? []) eventSet.add(event);
}
return eventSet.size ? Array.from(eventSet) : DEFAULT_NOTIFICATION_EVENTS;
}, [notificationEvents, providers]);
return { tableProviders, tableEvents };
}
export function NotificationsSettings() {
const state = useNotificationsState();
const { notificationPermission, bumpPermission } = useNotificationPermission();
const saveRequest = useSaveRequest(state);
const actions = useNotificationsActions(state, bumpPermission);
const isDirty = useIsDirty(state);
const { tableProviders, tableEvents } = useTableData(state);
const {
providers,
appriseAvailable,
appriseName,
setAppriseName,
appriseUrls,
setAppriseUrls,
showAppriseForm,
appriseFormMode,
activeAppriseId,
} = state;
const appriseProviders = providers.filter((provider) => provider.type === "apprise");
const handleSave = async () => {
try {
await saveRequest.run();
} catch (error) {
console.error("[NotificationsSettings] Failed to save notifications", error);
}
};
return (
<SettingsPageTemplate
title="Notifications"
description="Configure providers and choose which events should alert you."
isDirty={isDirty}
saveStatus={saveRequest.status}
onSave={handleSave}
>
<DesktopNotificationsSection
notificationPermission={notificationPermission}
onRequestPermission={actions.handleRequestPermission}
onRefreshPermission={actions.handleRefreshPermission}
onTestNotification={actions.handleTestNotification}
/>
<Separator className="my-4" />
<ExternalProvidersSection
appriseAvailable={appriseAvailable}
appriseProviders={appriseProviders}
appriseFormMode={appriseFormMode}
activeAppriseId={activeAppriseId}
appriseName={appriseName}
appriseUrls={appriseUrls}
showAppriseForm={showAppriseForm}
setAppriseName={setAppriseName}
setAppriseUrls={setAppriseUrls}
onAppriseNameEdit={actions.handleAppriseNameEdit}
onAppriseEdit={actions.handleAppriseEdit}
onOpenForm={actions.openAppriseForm}
onCloseForm={actions.closeAppriseForm}
onDeleteProvider={actions.handleDeleteProvider}
onTestProvider={actions.handleTestProvider}
onTextareaInput={handleTextareaInput}
onCreateAppriseProvider={actions.handleCreateAppriseProvider}
/>
<Separator className="my-4" />
<div className="space-y-4">
<div>
<div className="text-base font-medium">Notification Events</div>
<p className="text-sm text-muted-foreground">
Select which providers should receive each notification type.
</p>
</div>
{tableProviders.length > 0 && (
<NotificationEventsTable
tableProviders={tableProviders}
tableEvents={tableEvents}
onToggleEvent={actions.handleToggleEvent}
onTestProvider={actions.handleTestProvider}
/>
)}
</div>
</SettingsPageTemplate>
);
}
type AppriseProviderFormProps = {
mode: AppriseFormMode;
name: string;
urls: string;
onNameChange: (value: string) => void;
onUrlsChange: (value: string) => void;
onSubmit: () => void | Promise<void>;
onCancel: () => void;
onInput: (event: FormEvent<HTMLTextAreaElement>) => void;
};
function AppriseProviderForm({
mode,
name,
urls,
onNameChange,
onUrlsChange,
onSubmit,
onCancel,
onInput,
}: AppriseProviderFormProps) {
return (
<div className="rounded-lg border border-dashed border-muted p-4 space-y-3">
<div className="text-base font-medium">Apprise Provider</div>
<Input
value={name}
onChange={(event) => onNameChange(event.target.value)}
placeholder="Provider name"
/>
<Textarea
value={urls}
onChange={(event) => onUrlsChange(event.target.value)}
onInput={onInput}
placeholder="Service URL(s)"
rows={1}
className="min-h-0 h-auto"
/>
<div className="flex items-center gap-2">
<Button className="cursor-pointer" onClick={onSubmit}>
{mode === "create" ? "Add provider" : "Done"}
</Button>
<Button variant="ghost" className="cursor-pointer" onClick={onCancel}>
Cancel
</Button>
</div>
</div>
);
}
function handleTextareaInput(event: FormEvent<HTMLTextAreaElement>) {
const t = event.currentTarget;
t.style.height = "auto";
t.style.height = `${t.scrollHeight}px`;
}