c81b60a06d
* 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>
597 lines
20 KiB
TypeScript
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`;
|
|
}
|