Requests previously only notified the community server channels for submitted/approved/declined and the requester personally for fulfilled. This closes the gap and makes request posts addressable: - New request.approved / request.declined delivery types ride the operational dispatch path to the requesting profile: inbox, websocket toast, email, Discord DM, personal webhooks (gated by the existing notify_requests flag), and web push. Submitted stays broadcast-only (the requester performed the action themselves). Title/year/decline reason travel in reason_flags since no catalog item exists yet. - Request status notices are transactional: digest-mode recipients get an off-schedule early send (watermark-durable, last_digest_at left alone) instead of waiting for the digest hour. Per-episode recipients were already immediate via the dispatch nudge. - At-most-once per (profile, request, type) via a partial unique index (migration 20260612100000), mirroring the fulfilled dedupe. - Server-channel Discord request posts can @mention the requester via their OAuth-linked identity (notifications.server_channels. mention_requesters, default off). Resolved lazily in the sweep worker only when a Discord destination is about to receive the event; the ping uses content-level mention with pinned allowed_mentions, and the Discord identity never leaks into generic webhook payloads. Android/Apple clients render the new inbox types with their generic fallback until they add them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import { Fragment, useState } from "react";
|
||
import { Link } from "react-router";
|
||
import { Bell, BellOff, Check, CheckCheck, Loader2, Settings2 } from "lucide-react";
|
||
import type { AppNotification } from "@/api/types";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import {
|
||
formatEpisodeCode,
|
||
useMarkAllNotificationsRead,
|
||
useMarkNotificationRead,
|
||
useNotificationPreferences,
|
||
useNotifications,
|
||
useUnreadNotificationCount,
|
||
useUpdateNotificationPreferences,
|
||
} from "@/hooks/queries/notifications";
|
||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||
import { decodeThumbhash } from "@/lib/thumbhash";
|
||
|
||
function formatNotificationTime(value: string): string {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return "";
|
||
}
|
||
const diffMs = Date.now() - date.getTime();
|
||
const diffMinutes = Math.round(diffMs / 60_000);
|
||
if (diffMinutes < 1) {
|
||
return "Just now";
|
||
}
|
||
if (diffMinutes < 60) {
|
||
return `${diffMinutes}m ago`;
|
||
}
|
||
const diffHours = Math.round(diffMinutes / 60);
|
||
if (diffHours < 24) {
|
||
return `${diffHours}h ago`;
|
||
}
|
||
const diffDays = Math.round(diffHours / 24);
|
||
if (diffDays < 7) {
|
||
return `${diffDays}d ago`;
|
||
}
|
||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
}
|
||
|
||
function notificationTitle(notification: AppNotification): string {
|
||
if (notification.type === "episode.available") {
|
||
return notification.series_title || "New episode available";
|
||
}
|
||
if (notification.type === "request.fulfilled") {
|
||
return notification.series_title || "Request available";
|
||
}
|
||
if (notification.type === "request.approved" || notification.type === "request.declined") {
|
||
return notification.reason_flags?.title || "Media request";
|
||
}
|
||
// Unknown types render with a generic fallback by design — the type
|
||
// registry is extensible.
|
||
return "Notification";
|
||
}
|
||
|
||
function notificationDescription(notification: AppNotification): string {
|
||
if (notification.type === "episode.available") {
|
||
const code = formatEpisodeCode(notification);
|
||
return (
|
||
[code, notification.episode_title].filter(Boolean).join(" — ") || "New episode available"
|
||
);
|
||
}
|
||
if (notification.type === "request.fulfilled") {
|
||
const mediaType = notification.reason_flags?.media_type;
|
||
return mediaType === "movie"
|
||
? "Your requested movie is now available"
|
||
: mediaType === "series"
|
||
? "Your requested series is now available"
|
||
: "Your request is now available";
|
||
}
|
||
if (notification.type === "request.approved") {
|
||
return "Your request was approved";
|
||
}
|
||
if (notification.type === "request.declined") {
|
||
const reason = notification.reason_flags?.reason;
|
||
return reason ? `Your request was declined — ${reason}` : "Your request was declined";
|
||
}
|
||
return notification.type;
|
||
}
|
||
|
||
function reasonLabels(notification: AppNotification): string[] {
|
||
const flags = notification.reason_flags ?? {};
|
||
const labels: string[] = [];
|
||
if (flags.favorite) {
|
||
labels.push("Favorite");
|
||
}
|
||
if (flags.watchlist) {
|
||
labels.push("Watchlist");
|
||
}
|
||
if (flags.continue_watching) {
|
||
labels.push("Continue Watching");
|
||
}
|
||
if (flags.next_up) {
|
||
labels.push("Next Up");
|
||
}
|
||
return labels;
|
||
}
|
||
|
||
function NotificationRow({
|
||
notification,
|
||
onMarkRead,
|
||
}: {
|
||
notification: AppNotification;
|
||
onMarkRead: (id: string) => void;
|
||
}) {
|
||
const unread = !notification.read_at;
|
||
const thumbhashUrl = notification.poster_thumbhash
|
||
? decodeThumbhash(notification.poster_thumbhash)
|
||
: "";
|
||
const detailHref = notification.episode_id
|
||
? `/item/${notification.episode_id}`
|
||
: notification.series_id
|
||
? `/item/${notification.series_id}`
|
||
: null;
|
||
|
||
const body = (
|
||
<>
|
||
<div
|
||
className="bg-muted relative h-16 w-11 shrink-0 overflow-hidden rounded-md"
|
||
style={
|
||
thumbhashUrl
|
||
? {
|
||
backgroundImage: `url(${thumbhashUrl})`,
|
||
backgroundSize: "cover",
|
||
backgroundPosition: "center",
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
{notification.poster_url && (
|
||
<img
|
||
src={notification.poster_url}
|
||
alt=""
|
||
className="h-full w-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
{unread && (
|
||
<span
|
||
className="h-2 w-2 shrink-0 rounded-full"
|
||
style={{ background: "var(--primary)" }}
|
||
aria-label="Unread"
|
||
/>
|
||
)}
|
||
<span className={`truncate text-sm ${unread ? "font-semibold" : "font-medium"}`}>
|
||
{notificationTitle(notification)}
|
||
</span>
|
||
<span className="text-muted-foreground ml-auto shrink-0 text-xs">
|
||
{formatNotificationTime(notification.created_at)}
|
||
</span>
|
||
</div>
|
||
<div className="text-muted-foreground mt-0.5 truncate text-sm">
|
||
{notificationDescription(notification)}
|
||
</div>
|
||
{reasonLabels(notification).length > 0 && (
|
||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||
{reasonLabels(notification).map((label) => (
|
||
<span
|
||
key={label}
|
||
className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||
>
|
||
{label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<li className="group relative">
|
||
{detailHref ? (
|
||
<Link
|
||
to={detailHref}
|
||
onClick={() => unread && onMarkRead(notification.id)}
|
||
className={`hover:bg-muted/60 flex items-start gap-3 rounded-xl px-3 py-3 transition-colors ${
|
||
unread ? "bg-muted/30" : ""
|
||
}`}
|
||
>
|
||
{body}
|
||
</Link>
|
||
) : (
|
||
<div
|
||
className={`flex items-start gap-3 rounded-xl px-3 py-3 ${unread ? "bg-muted/30" : ""}`}
|
||
>
|
||
{body}
|
||
</div>
|
||
)}
|
||
{unread && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="absolute right-2 bottom-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
|
||
title="Mark as read"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
onMarkRead(notification.id);
|
||
}}
|
||
>
|
||
<Check className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function NotificationPreferencesPopover() {
|
||
const { data: prefs, isLoading, refetch } = useNotificationPreferences();
|
||
const updatePrefs = useUpdateNotificationPreferences();
|
||
|
||
const toggles: Array<{
|
||
key:
|
||
| "enabled"
|
||
| "notify_favorites"
|
||
| "notify_watchlist"
|
||
| "notify_continue_watching"
|
||
| "notify_next_up";
|
||
label: string;
|
||
description: string;
|
||
}> = [
|
||
{
|
||
key: "enabled",
|
||
label: "Notifications",
|
||
description: "Master switch for this profile",
|
||
},
|
||
{
|
||
key: "notify_favorites",
|
||
label: "Favorites",
|
||
description: "New episodes of favorited series",
|
||
},
|
||
{
|
||
key: "notify_watchlist",
|
||
label: "Watchlist",
|
||
description: "New episodes of watchlisted series",
|
||
},
|
||
{
|
||
key: "notify_continue_watching",
|
||
label: "Continue Watching",
|
||
description: "Series you are actively watching",
|
||
},
|
||
{
|
||
key: "notify_next_up",
|
||
label: "Next Up",
|
||
description: "The next episode after your progress",
|
||
},
|
||
];
|
||
|
||
return (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button variant="outline" size="sm">
|
||
<Settings2 className="mr-1.5 h-4 w-4" />
|
||
Preferences
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="end" className="w-80 p-4">
|
||
{isLoading ? (
|
||
<div className="space-y-3">
|
||
<Skeleton className="h-6 w-full" />
|
||
<Skeleton className="h-6 w-full" />
|
||
<Skeleton className="h-6 w-full" />
|
||
</div>
|
||
) : !prefs ? (
|
||
<div className="space-y-3">
|
||
<p className="text-muted-foreground text-sm">Couldn’t load preferences.</p>
|
||
<Button size="sm" variant="outline" onClick={() => void refetch()}>
|
||
Retry
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3.5">
|
||
{toggles.map((toggle, index) => (
|
||
<Fragment key={toggle.key}>
|
||
{index === 1 && <Separator />}
|
||
<div
|
||
className={`flex items-center justify-between gap-3 transition-opacity ${
|
||
index > 0 && !prefs.enabled ? "opacity-50" : ""
|
||
}`}
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium">{toggle.label}</div>
|
||
<div className="text-muted-foreground text-xs">{toggle.description}</div>
|
||
</div>
|
||
<Switch
|
||
checked={prefs[toggle.key]}
|
||
disabled={index > 0 && !prefs.enabled}
|
||
onCheckedChange={(checked) => updatePrefs.mutate({ [toggle.key]: checked })}
|
||
/>
|
||
</div>
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
)}
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
export default function Notifications() {
|
||
useDocumentTitle("Notifications");
|
||
const [statusFilter, setStatusFilter] = useState<"all" | "unread">("all");
|
||
const list = useNotifications(statusFilter);
|
||
const { data: unreadCount } = useUnreadNotificationCount();
|
||
const markRead = useMarkNotificationRead();
|
||
const markAllRead = useMarkAllNotificationsRead();
|
||
|
||
const notifications = list.data?.pages.flatMap((page) => page.notifications) ?? [];
|
||
|
||
return (
|
||
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||
<h1 className="flex items-center gap-2.5 text-2xl font-semibold">
|
||
<Bell className="h-6 w-6" />
|
||
Notifications
|
||
</h1>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{(unreadCount ?? 0) > 0 && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => markAllRead.mutate()}
|
||
disabled={markAllRead.isPending}
|
||
>
|
||
<CheckCheck className="mr-1.5 h-4 w-4" />
|
||
Mark all read
|
||
</Button>
|
||
)}
|
||
<NotificationPreferencesPopover />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-4 flex gap-1.5">
|
||
{(["all", "unread"] as const).map((status) => (
|
||
<Button
|
||
key={status}
|
||
variant={statusFilter === status ? "secondary" : "ghost"}
|
||
size="sm"
|
||
onClick={() => setStatusFilter(status)}
|
||
>
|
||
{status === "all" ? "All" : `Unread${unreadCount ? ` (${unreadCount})` : ""}`}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
{list.isLoading ? (
|
||
<div className="space-y-2">
|
||
{Array.from({ length: 5 }).map((_, index) => (
|
||
<Skeleton key={index} className="h-20 w-full rounded-xl" />
|
||
))}
|
||
</div>
|
||
) : notifications.length === 0 ? (
|
||
<div className="text-muted-foreground flex flex-col items-center gap-3 py-20 text-center">
|
||
<BellOff className="h-10 w-10 opacity-50" />
|
||
<div className="text-sm">
|
||
{statusFilter === "unread" ? "No unread notifications" : "No notifications yet"}
|
||
</div>
|
||
<div className="max-w-sm text-xs">
|
||
You will be notified here when new episodes arrive for series you favorite, watchlist,
|
||
or are watching.
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<ul className="list-none space-y-1">
|
||
{notifications.map((notification) => (
|
||
<NotificationRow
|
||
key={notification.id}
|
||
notification={notification}
|
||
onMarkRead={(id) => markRead.mutate(id)}
|
||
/>
|
||
))}
|
||
</ul>
|
||
{list.hasNextPage && (
|
||
<div className="mt-6 flex justify-center">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => list.fetchNextPage()}
|
||
disabled={list.isFetchingNextPage}
|
||
>
|
||
{list.isFetchingNextPage && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
Load more
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|