Files
silo-server/web/src/hooks/queries/notifications.ts
T

326 lines
10 KiB
TypeScript

import type { QueryClient } from "@tanstack/react-query";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
import type {
AppNotification,
NotificationDiscordLinkInit,
NotificationDiscordMode,
NotificationDiscordPreferences,
NotificationEmailPreferences,
NotificationEmailPreferencesUpdate,
NotificationListResponse,
NotificationPreferences,
NotificationReadEventPayload,
NotificationUnreadCountResponse,
} from "@/api/types";
import { notificationKeys } from "./keys";
import { toast } from "sonner";
const NOTIFICATIONS_PAGE_SIZE = 25;
export function useNotifications(status: "all" | "unread" = "all") {
return useInfiniteQuery({
queryKey: notificationKeys.list(status),
initialPageParam: "",
queryFn: ({ pageParam }) => {
const search = new URLSearchParams({ limit: String(NOTIFICATIONS_PAGE_SIZE) });
if (status === "unread") {
search.set("status", "unread");
}
if (pageParam) {
search.set("before", pageParam);
}
return api<NotificationListResponse>(`/notifications?${search.toString()}`);
},
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
}
export function useUnreadNotificationCount(enabled = true) {
return useQuery({
queryKey: notificationKeys.unreadCount(),
queryFn: () =>
api<NotificationUnreadCountResponse>("/notifications/unread-count").then((d) => d.count),
enabled,
staleTime: 30_000,
});
}
export function useMarkNotificationRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api(`/notifications/${id}/read`, { method: "POST" }),
onMutate: (id: string) => {
applyNotificationRead(queryClient, { profile_id: "", id });
},
onError: () => {
toast.error("Failed to mark notification read");
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
});
}
export function useMarkAllNotificationsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api("/notifications/read-all", { method: "POST" }),
onMutate: () => {
applyNotificationRead(queryClient, { profile_id: "", all: true });
},
onError: () => {
toast.error("Failed to mark notifications read");
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: notificationKeys.preferences(),
queryFn: () => api<NotificationPreferences>("/notifications/preferences"),
});
}
export function useUpdateNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (update: Partial<NotificationPreferences>) =>
api<NotificationPreferences>("/notifications/preferences", {
method: "PUT",
body: JSON.stringify(update),
}),
onSuccess: (prefs) => {
queryClient.setQueryData(notificationKeys.preferences(), prefs);
},
onError: () => {
toast.error("Failed to save notification preferences");
},
});
}
export function useEmailNotificationPreferences(enabled = true) {
return useQuery({
queryKey: notificationKeys.emailPreferences(),
queryFn: () => api<NotificationEmailPreferences>("/notifications/email-preferences"),
enabled,
});
}
export function useUpdateEmailNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (update: NotificationEmailPreferencesUpdate) =>
api<NotificationEmailPreferences>("/notifications/email-preferences", {
method: "PUT",
body: JSON.stringify(update),
}),
onSuccess: (prefs) => {
queryClient.setQueryData(notificationKeys.emailPreferences(), prefs);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to save email preferences");
},
});
}
export function useRequestEmailNotificationAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (email: string) =>
api<NotificationEmailPreferences>("/notifications/email-preferences/address", {
method: "PUT",
body: JSON.stringify({ email }),
}),
onSuccess: (prefs) => {
queryClient.setQueryData(notificationKeys.emailPreferences(), prefs);
toast.success(`Verification email sent to ${prefs.pending_email}`);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to send the verification email");
},
});
}
export function useClearEmailNotificationAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () =>
api<NotificationEmailPreferences>("/notifications/email-preferences/address", {
method: "DELETE",
}),
onSuccess: (prefs) => {
queryClient.setQueryData(notificationKeys.emailPreferences(), prefs);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to remove the custom address");
},
});
}
export function useDiscordNotificationPreferences(enabled = true) {
return useQuery({
queryKey: notificationKeys.discordPreferences(),
queryFn: () => api<NotificationDiscordPreferences>("/notifications/discord-preferences"),
enabled,
});
}
export function useUpdateDiscordNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (update: { mode: NotificationDiscordMode }) =>
api<NotificationDiscordPreferences>("/notifications/discord-preferences", {
method: "PUT",
body: JSON.stringify(update),
}),
onSuccess: (prefs) => {
queryClient.setQueryData(notificationKeys.discordPreferences(), prefs);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to save Discord preferences");
},
});
}
/** Starts the Discord account-link OAuth flow; navigate to the returned URL. */
export function useDiscordLinkInit() {
return useMutation({
mutationFn: () =>
api<NotificationDiscordLinkInit>("/notifications/discord/link/init", { method: "POST" }),
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to start Discord link");
},
});
}
export function useUnlinkDiscord() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api("/notifications/discord-link", { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.discordPreferences() });
toast.success("Discord account unlinked");
},
onError: () => {
toast.error("Failed to unlink Discord account");
},
});
}
// --- Realtime cache reducers (used by RealtimeEventsProvider) ---
type NotificationsInfiniteData = {
pages: NotificationListResponse[];
pageParams: unknown[];
};
function updateCachedLists(
queryClient: QueryClient,
update: (notification: AppNotification) => AppNotification,
) {
for (const status of ["all", "unread"] as const) {
queryClient.setQueryData<NotificationsInfiniteData>(notificationKeys.list(status), (data) =>
data
? {
...data,
pages: data.pages.map((page) => ({
...page,
notifications: page.notifications.map(update),
})),
}
: data,
);
}
}
/** Prepends a freshly created notification and bumps the unread badge. */
export function applyNotificationCreated(queryClient: QueryClient, notification: AppNotification) {
queryClient.setQueryData<NotificationsInfiniteData>(notificationKeys.list("all"), (data) => {
const first = data?.pages[0];
if (!data || !first) {
return data;
}
if (first.notifications.some((entry) => entry.id === notification.id)) {
return data;
}
return {
...data,
pages: [
{ ...first, notifications: [notification, ...first.notifications] },
...data.pages.slice(1),
],
};
});
void queryClient.invalidateQueries({ queryKey: notificationKeys.list("unread") });
if (!notification.read_at) {
queryClient.setQueryData<number>(notificationKeys.unreadCount(), (count) => (count ?? 0) + 1);
}
}
/** Applies a read event (single id or all) to cached rows and the badge. */
export function applyNotificationRead(
queryClient: QueryClient,
payload: NotificationReadEventPayload,
) {
const readAt = new Date().toISOString();
if (payload.all) {
updateCachedLists(queryClient, (entry) =>
entry.read_at ? entry : { ...entry, read_at: readAt },
);
queryClient.setQueryData<number>(notificationKeys.unreadCount(), 0);
return;
}
if (!payload.id) {
return;
}
let found = false;
let transitioned = false;
updateCachedLists(queryClient, (entry) => {
if (entry.id !== payload.id) {
return entry;
}
found = true;
if (entry.read_at) {
return entry;
}
transitioned = true;
return { ...entry, read_at: readAt };
});
// Decrement when we observed the unread -> read flip, or when the row is
// not cached at all (the backend only publishes read events on real
// transitions, so an unseen row was unread).
if (!found || transitioned) {
queryClient.setQueryData<number>(notificationKeys.unreadCount(), (count) =>
count == null ? count : Math.max(0, count - 1),
);
}
}
/** Hydrates the unread badge from the websocket snapshot (recent unread rows). */
export function applyNotificationsSnapshot(queryClient: QueryClient, rows: AppNotification[]) {
// The snapshot is capped (25 rows); use it as a lower bound and refresh the
// exact count only when the cap means the lower bound may be incomplete.
queryClient.setQueryData<number>(notificationKeys.unreadCount(), (count) =>
Math.max(count ?? 0, rows.length),
);
if (rows.length >= NOTIFICATIONS_PAGE_SIZE) {
void queryClient.invalidateQueries({ queryKey: notificationKeys.unreadCount() });
}
void queryClient.invalidateQueries({
queryKey: notificationKeys.list("all"),
refetchType: "active",
});
void queryClient.invalidateQueries({
queryKey: notificationKeys.list("unread"),
refetchType: "active",
});
}
/** Formats the "S2E5" style episode code for a notification row. */
export function formatEpisodeCode(notification: AppNotification): string | null {
if (notification.season_number == null || notification.episode_number == null) {
return null;
}
return `S${notification.season_number}E${notification.episode_number}`;
}