423 lines
13 KiB
TypeScript
423 lines
13 KiB
TypeScript
/**
|
|
* 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 = () => (
|
|
<svg viewBox="0 0 10 10" width="11" height="11" fill="currentColor">
|
|
<polygon points="5,1 9,9 1,9" />
|
|
</svg>
|
|
);
|
|
|
|
const FollowSvg = () => (
|
|
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
|
|
<polygon points="2,1 9,5 2,9" />
|
|
</svg>
|
|
);
|
|
|
|
const HeartSvg = () => (
|
|
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
|
|
<path d="M5 9 C2 6.5 0 5 0 2.8 C0 1.2 1.2 0 2.6 0 C3.6 0 4.5 0.6 5 1.5 C5.5 0.6 6.4 0 7.4 0 C8.8 0 10 1.2 10 2.8 C10 5 8 6.5 5 9 Z" />
|
|
</svg>
|
|
);
|
|
|
|
function NotifIcon({ type }: { type: Notification["type"] }) {
|
|
const kind = notifIconKind(type);
|
|
const glyphs: Record<NotifIconKind, React.ReactNode> = {
|
|
upvote: <UpvoteSvg />,
|
|
follow: <FollowSvg />,
|
|
dump: "🚚",
|
|
playlist: "📜",
|
|
mention: "@",
|
|
comment: "💬",
|
|
like: <HeartSvg />,
|
|
};
|
|
return (
|
|
<span className={`notif-icon notif-icon--${kind}`}>
|
|
{glyphs[kind]}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns where notification `n` leads. A notification about a comment links
|
|
* to its `#comment-<id>` 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 (
|
|
<Trans>
|
|
<strong>{d.followerUsername}</strong>
|
|
{" started following you"}
|
|
</Trans>
|
|
);
|
|
}
|
|
case "playlist_followed": {
|
|
const d = data as PlaylistFollowedData;
|
|
return (
|
|
<Trans>
|
|
<strong>{d.followerUsername}</strong>
|
|
{" followed your playlist "}
|
|
<strong>{d.playlistTitle}</strong>
|
|
</Trans>
|
|
);
|
|
}
|
|
case "user_dump_posted": {
|
|
const d = data as UserDumpPostedData;
|
|
return (
|
|
<Trans>
|
|
<strong>{d.dumperUsername}</strong>
|
|
{" posted "}
|
|
<strong>{d.dumpTitle}</strong>
|
|
</Trans>
|
|
);
|
|
}
|
|
case "playlist_dump_added": {
|
|
const d = data as PlaylistDumpAddedData;
|
|
return (
|
|
<Trans>
|
|
<strong>{d.dumpTitle}</strong>
|
|
{" was added to "}
|
|
<strong>{d.playlistTitle}</strong>
|
|
</Trans>
|
|
);
|
|
}
|
|
case "dump_upvoted": {
|
|
const d = data as DumpUpvotedData;
|
|
return (
|
|
<Trans>
|
|
<strong>{d.voterUsername}</strong>
|
|
{" upvoted "}
|
|
<strong>{d.dumpTitle}</strong>
|
|
</Trans>
|
|
);
|
|
}
|
|
case "dump_commented": {
|
|
const d = data as DumpCommentedData;
|
|
return (
|
|
<Trans>
|
|
<strong>{d.commenterUsername}</strong>
|
|
{" commented on "}
|
|
<strong>{d.dumpTitle}</strong>
|
|
</Trans>
|
|
);
|
|
}
|
|
case "comment_liked": {
|
|
const d = data as CommentLikedData;
|
|
return (
|
|
<Trans>
|
|
<strong>{d.likerUsername}</strong>
|
|
{" liked your comment on "}
|
|
<strong>{d.dumpTitle}</strong>
|
|
</Trans>
|
|
);
|
|
}
|
|
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 (
|
|
<Trans>
|
|
<strong>{d.mentionerUsername}</strong>
|
|
{" mentioned you in "}
|
|
<strong>{where}</strong>
|
|
</Trans>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<string, Notification[]> = {};
|
|
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<Notification[]>([]);
|
|
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<Notification | null | undefined>(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 (
|
|
<PageShell>
|
|
<div className="notifications-page">
|
|
<div className="notifications-header">
|
|
<h1 className="notifications-title">
|
|
<span className="notifications-title-bell">🔔</span>
|
|
<Trans>Notifications</Trans>
|
|
</h1>
|
|
{state.status === "loaded" && totalUnread > 0 && (
|
|
<span className="notifications-unread-pill">
|
|
{totalUnread} <Trans>new</Trans>
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{state.status === "loading" && (
|
|
<p className="page-loading">
|
|
<Trans>Loading…</Trans>
|
|
</p>
|
|
)}
|
|
{state.status === "error" && (
|
|
<ErrorCard title={t`Failed to load`} message={state.error} />
|
|
)}
|
|
|
|
{state.status === "loaded" && items.length === 0 && (
|
|
<div className="notifications-empty">
|
|
<span className="notifications-empty-icon">🔕</span>
|
|
<p>
|
|
<Trans>Nothing here yet.</Trans>
|
|
</p>
|
|
<p className="notifications-empty-hint">
|
|
<Trans>
|
|
You'll be notified when someone follows your playlists, upvotes
|
|
your dumps, or posts new content.
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === "loaded" && items.length > 0 &&
|
|
groupByDate(items).map(({ label, items }) => (
|
|
<section key={label} className="notif-group">
|
|
<h2 className="notif-group-label">
|
|
{label === "Today"
|
|
? t`Today`
|
|
: label === "Yesterday"
|
|
? t`Yesterday`
|
|
: t`Earlier`}
|
|
</h2>
|
|
<ul className="notification-list">
|
|
{items.map((n) => (
|
|
<li
|
|
key={n.id}
|
|
className={`notification-item${
|
|
!n.read ? " notification-item--unread" : ""
|
|
}`}
|
|
>
|
|
<Link
|
|
to={notificationLink(n)}
|
|
className="notification-item-link"
|
|
>
|
|
<NotifIcon type={n.type} />
|
|
<div className="notification-body">
|
|
<span className="notification-content">
|
|
{notificationContent(n)}
|
|
</span>
|
|
<Tooltip text={n.createdAt.toLocaleString()}>
|
|
<time
|
|
className="notification-time"
|
|
dateTime={n.createdAt.toISOString()}
|
|
>
|
|
{timeAgo(n.createdAt)}
|
|
</time>
|
|
</Tooltip>
|
|
</div>
|
|
{!n.read && (
|
|
<span className="notif-dot" aria-hidden="true" />
|
|
)}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
))}
|
|
|
|
{state.status === "loaded" && state.data.hasMore && (
|
|
<LoadMoreButton
|
|
loadingMore={state.data.loadingMore}
|
|
onClick={loadMore}
|
|
/>
|
|
)}
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|