Files
kandev/apps/web/components/settings/notifications-settings-actions.ts
T

367 lines
12 KiB
TypeScript

"use client";
import { useState } from "react";
import {
createNotificationProvider,
deleteNotificationProvider,
testNotificationProvider,
updateNotificationProvider,
} from "@/lib/api";
import { useRequest } from "@/lib/http/use-request";
import { DEFAULT_NOTIFICATION_EVENTS } from "@/lib/notifications/events";
import { useNotificationProviders } from "@/hooks/domains/settings/use-notification-providers";
import type { NotificationProvider } from "@/lib/types/http";
type ProviderUpdatePayload = {
enabled?: boolean;
events?: string[];
config?: NotificationProvider["config"];
name?: string;
};
type AppriseFormMode = "create" | "edit";
export function formatAppriseUrls(value: unknown): string {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string").join("\n");
}
if (typeof value === "string") {
return value;
}
return "";
}
export function parseAppriseUrls(value: string): string[] {
return value
.split("\n")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function normalizeEvents(events?: string[]): string {
if (!Array.isArray(events)) {
return "";
}
return [...events].sort().join("|");
}
function extractUrls(config: NotificationProvider["config"]): string[] {
if (!config) return [];
const urls = config.urls;
if (Array.isArray(urls)) {
return urls.filter((item): item is string => typeof item === "string");
}
if (typeof urls === "string") {
return parseAppriseUrls(urls);
}
return [];
}
export function buildProviderUpdate(
provider: NotificationProvider,
baseline: NotificationProvider,
): ProviderUpdatePayload | null {
const updates: ProviderUpdatePayload = {};
if (provider.enabled !== baseline.enabled) updates.enabled = provider.enabled;
if (normalizeEvents(provider.events) !== normalizeEvents(baseline.events)) {
updates.events = provider.events;
}
if (provider.name !== baseline.name) updates.name = provider.name;
if (provider.type === "apprise") {
const urls = extractUrls(provider.config);
const baselineUrls = extractUrls(baseline.config);
if (urls.join("|") !== baselineUrls.join("|")) {
updates.config = { ...provider.config, urls };
}
}
return Object.keys(updates).length ? updates : null;
}
function buildAppriseEdits(providers: NotificationProvider[]) {
const urls: Record<string, string> = {};
const names: Record<string, string> = {};
for (const provider of providers) {
if (provider.type !== "apprise") continue;
urls[provider.id] = formatAppriseUrls(provider.config?.urls);
names[provider.id] = provider.name;
}
return { urls, names };
}
export function useNotificationsState() {
const {
providers: storeProviders,
events: storeEvents,
appriseAvailable: storeAppriseAvailable,
} = useNotificationProviders();
const [providers, setProviders] = useState<NotificationProvider[]>(() => storeProviders ?? []);
const [baselineProviders, setBaselineProviders] = useState<NotificationProvider[]>(
() => storeProviders ?? [],
);
const [notificationEvents] = useState<string[]>(() => storeEvents ?? []);
const [appriseAvailable] = useState(() => storeAppriseAvailable ?? true);
const [appriseName, setAppriseName] = useState("");
const [appriseUrls, setAppriseUrls] = useState("");
const [appriseEdits, setAppriseEdits] = useState<Record<string, string>>(
() => buildAppriseEdits(storeProviders ?? []).urls,
);
const [appriseNameEdits, setAppriseNameEdits] = useState<Record<string, string>>(
() => buildAppriseEdits(storeProviders ?? []).names,
);
const [showAppriseForm, setShowAppriseForm] = useState(false);
const [appriseFormMode, setAppriseFormMode] = useState<AppriseFormMode>("create");
const [activeAppriseId, setActiveAppriseId] = useState<string | null>(null);
const [pendingDeletes, setPendingDeletes] = useState<Set<string>>(new Set());
return {
providers,
setProviders,
baselineProviders,
setBaselineProviders,
notificationEvents,
appriseAvailable,
appriseName,
setAppriseName,
appriseUrls,
setAppriseUrls,
appriseEdits,
setAppriseEdits,
appriseNameEdits,
setAppriseNameEdits,
showAppriseForm,
setShowAppriseForm,
appriseFormMode,
setAppriseFormMode,
activeAppriseId,
setActiveAppriseId,
pendingDeletes,
setPendingDeletes,
};
}
export type NotificationsState = ReturnType<typeof useNotificationsState>;
export type { AppriseFormMode };
export function useSaveRequest(state: NotificationsState) {
const {
providers,
baselineProviders,
setProviders,
setBaselineProviders,
setAppriseEdits,
setAppriseNameEdits,
pendingDeletes,
setPendingDeletes,
} = state;
return useRequest(async () => {
const updates: Array<Promise<NotificationProvider>> = [];
for (const provider of providers) {
const baseline = baselineProviders.find((item) => item.id === provider.id);
if (!baseline) continue;
const payload = buildProviderUpdate(provider, baseline);
if (!payload) continue;
updates.push(updateNotificationProvider(provider.id, payload));
}
for (const providerId of Array.from(pendingDeletes)) {
await deleteNotificationProvider(providerId);
}
const updated = await Promise.all(updates);
if (updated.length === 0) {
setBaselineProviders(providers);
setPendingDeletes(new Set());
return [] as NotificationProvider[];
}
const updatedById = new Map(updated.map((provider) => [provider.id, provider]));
const nextProviders = providers.map((provider) => updatedById.get(provider.id) ?? provider);
setProviders(nextProviders);
setBaselineProviders(nextProviders);
setAppriseEdits((prev) => {
const next = { ...prev };
for (const p of nextProviders) {
if (p.type === "apprise") next[p.id] = formatAppriseUrls(p.config?.urls);
}
return next;
});
setAppriseNameEdits((prev) => {
const next = { ...prev };
for (const p of nextProviders) {
if (p.type === "apprise") next[p.id] = p.name;
}
return next;
});
setPendingDeletes(new Set());
return updated;
});
}
export function useIsDirty(state: NotificationsState) {
const { providers, baselineProviders, appriseEdits, appriseNameEdits, pendingDeletes } = state;
return (
pendingDeletes.size > 0 ||
providers.some((provider) => {
const baseline = baselineProviders.find((item) => item.id === provider.id);
if (!baseline) return false;
if (buildProviderUpdate(provider, baseline)) return true;
if (provider.type === "apprise") {
const currentValue = appriseEdits[provider.id] ?? formatAppriseUrls(provider.config?.urls);
const baselineValue = formatAppriseUrls(baseline.config?.urls);
const nameValue = appriseNameEdits[provider.id] ?? provider.name;
return currentValue !== baselineValue || nameValue !== baseline.name;
}
return false;
})
);
}
function useAppriseProviderActions(state: NotificationsState) {
const {
notificationEvents,
appriseName,
appriseUrls,
appriseEdits,
appriseNameEdits,
setProviders,
setBaselineProviders,
setAppriseEdits,
setAppriseNameEdits,
setAppriseName,
setAppriseUrls,
setShowAppriseForm,
setAppriseFormMode,
setActiveAppriseId,
setPendingDeletes,
} = state;
const updateProviderState = (
providerId: string,
updater: (p: NotificationProvider) => NotificationProvider,
) => {
setProviders((prev) => prev.map((p) => (p.id === providerId ? updater(p) : p)));
};
const handleCreateAppriseProvider = async () => {
const urls = parseAppriseUrls(appriseUrls);
if (urls.length === 0) return;
const defaultEvents =
notificationEvents.length > 0 ? notificationEvents : DEFAULT_NOTIFICATION_EVENTS;
try {
const created = await createNotificationProvider({
name: appriseName.trim() || "Apprise",
type: "apprise",
config: { urls },
enabled: true,
events: defaultEvents,
});
setProviders((prev) => [...prev, created]);
setBaselineProviders((prev) => [...prev, created]);
setAppriseEdits((prev) => ({ ...prev, [created.id]: urls.join("\n") }));
setAppriseNameEdits((prev) => ({ ...prev, [created.id]: created.name }));
setAppriseUrls("");
setShowAppriseForm(false);
} catch (error) {
console.error("[NotificationsSettings] Failed to create apprise provider", error);
}
};
const handleAppriseEdit = (providerId: string, value: string) => {
setAppriseEdits((prev) => ({ ...prev, [providerId]: value }));
updateProviderState(providerId, (p) => ({
...p,
config: { ...p.config, urls: parseAppriseUrls(value) },
}));
};
const handleAppriseNameEdit = (providerId: string, value: string) => {
setAppriseNameEdits((prev) => ({ ...prev, [providerId]: value }));
updateProviderState(providerId, (p) => ({ ...p, name: value }));
};
const openAppriseForm = (mode: AppriseFormMode, provider?: NotificationProvider) => {
setAppriseFormMode(mode);
setActiveAppriseId(provider?.id ?? null);
if (provider) {
setAppriseName(appriseNameEdits[provider.id] ?? provider.name);
setAppriseUrls(appriseEdits[provider.id] ?? formatAppriseUrls(provider.config?.urls));
} else {
setAppriseName("");
setAppriseUrls("");
}
setShowAppriseForm(true);
};
const handleDeleteProvider = (providerId: string) => {
setPendingDeletes((prev) => new Set(prev).add(providerId));
setProviders((prev) => prev.filter((p) => p.id !== providerId));
};
const closeAppriseForm = () => {
setShowAppriseForm(false);
setActiveAppriseId(null);
};
return {
handleCreateAppriseProvider,
handleAppriseEdit,
handleAppriseNameEdit,
openAppriseForm,
handleDeleteProvider,
closeAppriseForm,
updateProviderState,
};
}
export function useNotificationsActions(state: NotificationsState, bumpPermission: () => void) {
const { setProviders } = state;
const appriseActions = useAppriseProviderActions(state);
const handleToggleEvent = (provider: NotificationProvider, eventType: string) => {
const currentEvents = provider.events ?? [];
const hasEvent = currentEvents.includes(eventType);
const nextEvents = hasEvent
? currentEvents.filter((e) => e !== eventType)
: [...currentEvents, eventType];
setProviders((prev) =>
prev.map((p) => (p.id === provider.id ? { ...p, events: nextEvents } : p)),
);
};
const handleRequestPermission = async () => {
if (typeof Notification === "undefined") return;
await Notification.requestPermission();
bumpPermission();
};
const handleRefreshPermission = () => {
if (typeof Notification !== "undefined") bumpPermission();
};
const handleTestNotification = async () => {
if (typeof Notification === "undefined") return;
let permission = Notification.permission;
if (permission === "default") {
permission = await Notification.requestPermission();
bumpPermission();
}
if (permission !== "granted") return;
new Notification("Test notification", {
body: "If you can read this, browser notifications are working.",
});
};
const handleTestProvider = async (providerId: string) => {
try {
await testNotificationProvider(providerId);
} catch (error) {
console.error("[NotificationsSettings] Test notification failed", error);
}
};
return {
handleToggleEvent,
handleRequestPermission,
handleRefreshPermission,
handleTestNotification,
handleTestProvider,
...appriseActions,
};
}