/** * Renders the notifications page, `/notifications`, for a signed-in user. It * reads them from `GET /api/notifications` and adds the ones `WSContext` * receives while it is open. * * @module */ import { useEffect, useMemo, useRef, 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 { LoadMoreButton } from "../components/shared/LoadMoreButton.tsx"; import { useWS } from "../hooks/ws/useWS.ts"; import type { CommentLikedData, DumpCommentedData, DumpUpvotedData, Notification, NotificationData, PlaylistDumpAddedData, PlaylistFollowedData, UserDumpPostedData, UserFollowedData, UserMentionedData, } from "../../shared/notification.ts"; import { deserializeNotification } from "../utils/shared/deserialize.ts"; import { pageUrl, readPage } from "../utils/feed/pagedList.ts"; import { usePagedList } from "../hooks/feed/usePagedList.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; 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]} ); } /** * Returns where notification `n` leads. A notification about a comment links * to its `#comment-` anchor on the dump's page. */ 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}`; // A query string alone keeps this page, and `ChatProvider` opens the // chat over it. 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} ); } } } /** * Returns how long ago `date` was when it was within a week, else its day and * month. */ 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" }); } /** Returns the local midnight that starts the day of `d`, in epoch ms. */ function startOfDay(d: Date): number { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); } /** * Returns `items` grouped under Today, Yesterday and Earlier, in that order, * without the empty groups. The labels are keys, which the page translates. */ 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] })); } /** * Renders `/notifications`, for a signed-in user. Opening the page marks every * notification read on the server, and so does each that arrives while it is * open. The unread ones stay highlighted until the page is left. */ export function Notifications(): React.JSX.Element { useDocumentTitle(t`Notifications`); const { user, authFetch } = useAuth(); const { clearUnreadNotifications, lastNotification } = useWS(); const { state, loadMore } = usePagedList( user?.id, (page, signal) => authFetch( pageUrl(`${API_URL}/api/notifications`, page, NOTIFICATIONS_PAGE_SIZE), { signal }, ).then((res) => readPage(res, deserializeNotification)), ); // Each new `lastNotification` is kept once, during render rather than in an // effect, so it shows in the same frame. The list may predate it or not. const [arrived, setArrived] = useState([]); const [prevLastNotification, setPrevLastNotification] = useState( lastNotification, ); if (prevLastNotification !== lastNotification && lastNotification !== null) { setPrevLastNotification(lastNotification); setArrived((a) => [lastNotification, ...a]); } const items = useMemo(() => { if (state.status !== "loaded") return []; const listed = new Set(state.data.items.map((n) => n.id)); return [...arrived.filter((n) => !listed.has(n.id)), ...state.data.items]; }, [state, arrived]); // Every notification is marked read on the server once the list shows, and // again for each that arrives while the page is open. Only the bell clears: // clearing the list too would take its dots and "new" count away a moment // after they appear. A failure leaves them unread for next time. const loaded = state.status === "loaded"; const markedReadRef = useRef(undefined); useEffect(() => { if (!loaded || markedReadRef.current === lastNotification) return; markedReadRef.current = lastNotification; authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" }) .then((res) => { if (res.ok) clearUnreadNotifications(); }) .catch(() => {}); }, [lastNotification, loaded, authFetch, clearUnreadNotifications]); const totalUnread = items.filter((n) => !n.read).length; return (

πŸ”” Notifications

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

Loading…

)} {state.status === "error" && ( )} {state.status === "loaded" && 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" && items.length > 0 && groupByDate(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.data.hasMore && ( )}
); }