490 lines
15 KiB
TypeScript
490 lines
15 KiB
TypeScript
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 = () => (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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<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] }));
|
|
}
|
|
|
|
export function Notifications() {
|
|
useDocumentTitle(t`Notifications`);
|
|
const { authFetch } = useAuth();
|
|
const { clearUnreadNotifications, lastNotification } = useWS();
|
|
const [state, setState] = useState<State>({
|
|
status: "loading",
|
|
arrived: [],
|
|
});
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const data = await expectOk<PaginatedData<RawNotification>>(
|
|
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<RawNotification>;
|
|
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 (
|
|
<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" && state.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" && state.items.length > 0 &&
|
|
groupByDate(state.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.hasMore && (
|
|
<button
|
|
type="button"
|
|
className="load-more-btn"
|
|
onClick={loadMore}
|
|
disabled={state.loadingMore}
|
|
>
|
|
{state.loadingMore
|
|
? <Trans>Loading…</Trans>
|
|
: <Trans>Load more</Trans>}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|