import { useEffect, useState } from "react"; import { Link } from "react-router"; import { API_URL } from "../config/api.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { ErrorCard } from "../components/ErrorCard.tsx"; import { useWS } from "../hooks/useWS.ts"; import type { DumpUpvotedData, Notification, NotificationData, PaginatedData, PlaylistDumpAddedData, PlaylistFollowedData, RawNotification, UserDumpPostedData, UserFollowedData, } from "../model.ts"; import { deserializeNotification } from "../model.ts"; import { PageShell } from "../components/PageShell.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; const PAGE_SIZE = 30; type State = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; items: Notification[]; hasMore: boolean; page: number; loadingMore: boolean; }; type NotifIconKind = "upvote" | "follow" | "dump" | "playlist"; function notifIconKind(type: Notification["type"]): NotifIconKind { switch (type) { case "dump_upvoted": return "upvote"; case "playlist_followed": return "follow"; case "user_followed": return "follow"; case "user_dump_posted": return "dump"; case "playlist_dump_added": return "playlist"; } } function NotifIcon({ type }: { type: Notification["type"] }) { const kind = notifIconKind(type); const glyphs: Record = { upvote: "▲", follow: "►", dump: "🚚", playlist: "📜", }; return ( {glyphs[kind]} ); } function notificationContent(n: Notification): React.ReactNode { const data = n.data as NotificationData; switch (n.type) { case "user_followed": { const d = data as UserFollowedData; return ( <> {d.followerUsername} {" started following you"} ); } case "playlist_followed": { const d = data as PlaylistFollowedData; return ( <> {d.followerUsername} {" followed your playlist "} {d.playlistTitle} ); } case "user_dump_posted": { const d = data as UserDumpPostedData; return ( <> {d.dumperUsername} {" posted "} {d.dumpTitle} ); } case "playlist_dump_added": { const d = data as PlaylistDumpAddedData; return ( <> {d.dumpTitle} {" was added to "} {d.playlistTitle} ); } case "dump_upvoted": { const d = data as DumpUpvotedData; return ( <> {d.voterUsername} {" upvoted "} {d.dumpTitle} ); } default: return "New notification"; } } function timeAgo(date: Date): string { const secs = Math.floor((Date.now() - date.getTime()) / 1000); if (secs < 60) return "just now"; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 7) return `${days}d ago`; return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } function startOfDay(d: Date): number { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); } function groupByDate( items: Notification[], ): { label: string; items: Notification[] }[] { const todayTs = startOfDay(new Date()); const yesterdayTs = todayTs - 86_400_000; const buckets: Record = {}; for (const n of items) { const ts = startOfDay(n.createdAt); const key = ts >= todayTs ? "Today" : ts >= yesterdayTs ? "Yesterday" : "Earlier"; (buckets[key] ??= []).push(n); } return (["Today", "Yesterday", "Earlier"] as const) .filter((k) => buckets[k]?.length) .map((label) => ({ label, items: buckets[label] })); } export function Notifications() { const { authFetch } = useAuth(); const { clearUnreadNotifications, lastNotification } = useWS(); const [state, setState] = useState({ status: "loading" }); useEffect(() => { // 1. Fetch with original read state so unread items are highlighted // 2. Only after displaying, mark all read on the server authFetch(`${API_URL}/api/notifications?page=1&limit=${PAGE_SIZE}`) .then((r) => r.json()) .then((body) => { if (!body.success) throw new Error("Failed to load"); const data = body.data as PaginatedData; setState({ status: "loaded", items: data.items.map(deserializeNotification), hasMore: data.hasMore, page: 1, loadingMore: false, }); // Mark read server-side after we've shown the unread state return authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST", }); }) .then(() => { clearUnreadNotifications(); setState((s) => s.status === "loaded" ? { ...s, items: s.items.map((n) => ({ ...n, read: true })) } : s ); }) .catch((err) => { if (err instanceof TypeError) { setState({ status: "error", error: friendlyFetchError(err) }); } else if (err instanceof Error && err.message === "Failed to load") { setState({ status: "error", error: err.message }); } }); }, []); useEffect(() => { if (!lastNotification) return; setState((s) => { if (s.status !== "loaded") return s; if (s.items.some((n) => n.id === lastNotification.id)) return s; // Keep as unread so it gets highlighted when it arrives return { ...s, items: [lastNotification, ...s.items] }; }); }, [lastNotification]); const loadMore = () => { if (state.status !== "loaded" || !state.hasMore || state.loadingMore) { return; } const nextPage = state.page + 1; setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); authFetch( `${API_URL}/api/notifications?page=${nextPage}&limit=${PAGE_SIZE}`, ) .then((r) => r.json()) .then((body) => { const data = body.data as PaginatedData; setState((s) => s.status === "loaded" ? { ...s, items: [...s.items, ...data.items.map(deserializeNotification)], hasMore: data.hasMore, page: nextPage, loadingMore: false, } : s ); }) .catch(() => setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s ) ); }; const totalUnread = state.status === "loaded" ? state.items.filter((n) => !n.read).length : 0; return (

🔔 Notifications

{state.status === "loaded" && totalUnread > 0 && ( {totalUnread} new )}
{state.status === "loading" &&

Loading…

} {state.status === "error" && ( )} {state.status === "loaded" && state.items.length === 0 && (
🔕

Nothing here yet.

You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.

)} {state.status === "loaded" && state.items.length > 0 && groupByDate(state.items).map(({ label, items }) => (

{label}

    {items.map((n) => (
  • {notificationContent(n)}
    {!n.read && (
  • ))}
))} {state.status === "loaded" && state.hasMore && ( )}
); }