import React, { useEffect, useState } from "react"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { relativeTime } from "../utils/shared/relativeTime.ts"; import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { ErrorCard } from "../components/shared/ErrorCard.tsx"; import { Tooltip } from "../components/shared/Tooltip.tsx"; import { useWS } from "../hooks/ws/useWS.ts"; import type { CommentLikedData, DumpCommentedData, DumpUpvotedData, Notification, NotificationData, PaginatedData, PlaylistDumpAddedData, PlaylistFollowedData, RawNotification, UserDumpPostedData, UserFollowedData, UserMentionedData, } from "../model.ts"; import { deserializeNotification } from "../model.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; import { friendlyFetchError } from "../utils/shared/api.ts"; import { expectOk } from "../utils/shared/api.ts"; type State = // `arrived`: sent over the socket before the list came back — the list may // predate them, and the read-all that follows the load covers them anyway. | { status: "loading"; arrived: Notification[] } | { status: "error"; error: string } | { status: "loaded"; items: Notification[]; hasMore: boolean; page: number; loadingMore: boolean; }; type NotifIconKind = | "upvote" | "follow" | "dump" | "playlist" | "mention" | "comment" | "like"; 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"; case "user_mentioned": return "mention"; case "dump_commented": return "comment"; case "comment_liked": return "like"; } } const UpvoteSvg = () => ( ); const FollowSvg = () => ( ); const HeartSvg = () => ( ); function NotifIcon({ type }: { type: Notification["type"] }) { const kind = notifIconKind(type); const glyphs: Record = { upvote: , follow: , dump: "🚚", playlist: "📜", mention: "@", comment: "💬", like: , }; return ( {glyphs[kind]} ); } function notificationLink(n: Notification): string { const data = n.data as NotificationData; switch (n.type) { case "user_followed": return `/users/${(data as UserFollowedData).followerUsername}`; case "playlist_followed": return `/playlists/${(data as PlaylistFollowedData).playlistId}`; case "user_dump_posted": return `/dumps/${(data as UserDumpPostedData).dumpId}`; case "playlist_dump_added": return `/dumps/${(data as PlaylistDumpAddedData).dumpId}`; case "dump_upvoted": return `/dumps/${(data as DumpUpvotedData).dumpId}`; case "dump_commented": return `/dumps/${(data as DumpCommentedData).dumpId}#comment-${ (data as DumpCommentedData).commentId }`; case "comment_liked": return `/dumps/${(data as CommentLikedData).dumpId}#comment-${ (data as CommentLikedData).commentId }`; case "user_mentioned": { const d = data as UserMentionedData; if (d.contextType === "comment") { return `/dumps/${d.dumpId}#comment-${d.contextId}`; } if (d.contextType === "dump") return `/dumps/${d.contextId}`; // Search only: the chat opens over whatever page you're on. if (d.contextType === "chat") return `?chat=open`; return `/playlists/${d.contextId}`; } } } 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} ); } case "dump_commented": { const d = data as DumpCommentedData; return ( {d.commenterUsername} {" commented on "} {d.dumpTitle} ); } case "comment_liked": { const d = data as CommentLikedData; return ( {d.likerUsername} {" liked your comment on "} {d.dumpTitle} ); } case "user_mentioned": { const d = data as UserMentionedData; const where = d.contextTitle || (d.contextType === "comment" ? t`a comment` : d.contextType === "chat" ? t`the chat` : t`a post`); return ( {d.mentionerUsername} {" mentioned you in "} {where} ); } } } function timeAgo(date: Date): string { const abs = Math.abs(Date.now() - date.getTime()) / 1000; if (abs < 7 * 86400) return relativeTime(date); 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() { useDocumentTitle(t`Notifications`); const { authFetch } = useAuth(); const { clearUnreadNotifications, lastNotification } = useWS(); const [state, setState] = useState({ status: "loading", arrived: [], }); useEffect(() => { let cancelled = false; (async () => { try { const data = await expectOk>( await authFetch( `${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`, ), ); if (cancelled) return; const items = data.items.map(deserializeNotification); setState((s) => ({ status: "loaded", items: [ ...(s.status === "loading" ? s.arrived : []).filter((a) => !items.some((n) => n.id === a.id) ), ...items, ], hasMore: data.hasMore, page: 1, loadingMore: false, })); } catch (err) { if (!cancelled) { setState({ status: "error", error: friendlyFetchError(err) }); } return; } // Marking them read is a separate matter: if it fails, the list that // loaded is still worth showing, and they stay unread for next time. try { const res = await authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST", }); if (cancelled || !res.ok) return; // Only the bell clears. The list keeps showing which ones were new // when the page opened: flipping them to read here, a moment after // they appear, would take the dots and the "new" count away before // anyone could see them. clearUnreadNotifications(); } catch { // Offline: see above. } })(); return () => { cancelled = true; }; }, [authFetch, clearUnreadNotifications]); const [prevLastNotification, setPrevLastNotification] = useState( lastNotification, ); if (prevLastNotification !== lastNotification && lastNotification !== null) { setPrevLastNotification(lastNotification); setState((s) => { if (s.status === "loading") { return { ...s, arrived: [lastNotification, ...s.arrived] }; } if (s.status !== "loaded") return s; if (s.items.some((n) => n.id === lastNotification.id)) return s; return { ...s, items: [lastNotification, ...s.items] }; }); } // One that arrives while the page is open is being seen: it stays // highlighted here, but the bell shouldn't count it. const loaded = state.status === "loaded"; useEffect(() => { if (!lastNotification || !loaded) return; authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" }) .then((res) => { if (res.ok) clearUnreadNotifications(); }) .catch(() => {}); }, [lastNotification, loaded, authFetch, clearUnreadNotifications]); 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=${NOTIFICATIONS_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 === "Today" ? t`Today` : label === "Yesterday" ? t`Yesterday` : t`Earlier`}

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