Files
kandev/apps/web/components/settings/notifications-settings.tsx
T
João Salavisa c81b60a06d feat(web): add opt-in notification sound for waiting-for-input (#1689)
* feat(web): add opt-in notification sound for waiting-for-input

Play a configurable per-device sound when a session starts waiting for
user input. Sounds are synthesized with the Web Audio API (no binary
assets), follow the same suppression rules as desktop notifications,
and work independently of the browser Notification permission.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): drop stale sound plays when AudioContext resume is delayed

Scheduling notes on a suspended context queued every pending alert and
played them stacked once the user finally interacted. Schedule only
after resume() fulfills and drop plays older than 2s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): harden notification sound playback edge cases

Catch AudioContext construction failures so a broken audio stack cannot
abort the WS handler before the desktop notification is shown, route
interrupted (iOS) contexts through the resume path, and coalesce plays
queued while the context is not running so only one pending cue fires.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): make sound test stubs constructible and refine resume queue

vi.fn with an arrow implementation is not constructible, so the
AudioContext stubs silently misbehaved on CI's Node runtime. Also keep
the newest pending play instead of dropping later requests, recheck the
preference before a resumed waiting-for-input cue fires, and validate
picker values with the existing type guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 10:27:23 +01:00

597 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 { NotificationSoundSection } from "@/components/settings/notification-sound-section";
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" />
<NotificationSoundSection />
<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`;
}