Files
gerbeur/src/pages/Notifications.tsx
khannurien 6c4f410d9b
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
v3: added a chatbox
2026-06-29 20:25:10 +00:00

454 lines
13 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/relativeTime.ts";
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx";
import { useWS } from "../hooks/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/PageShell.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
type State =
| { status: "loading" }
| { 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}`;
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" });
useEffect(() => {
authFetch(
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("Failed to load");
const data = body.data as PaginatedData<RawNotification>;
setState({
status: "loaded",
items: data.items.map(deserializeNotification),
hasMore: data.hasMore,
page: 1,
loadingMore: false,
});
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 });
}
});
}, [authFetch, clearUnreadNotifications]);
const [prevLastNotification, setPrevLastNotification] = useState(
lastNotification,
);
if (prevLastNotification !== lastNotification && lastNotification !== null) {
setPrevLastNotification(lastNotification);
setState((s) => {
if (s.status !== "loaded") return s;
if (s.items.some((n) => n.id === lastNotification.id)) return s;
return { ...s, items: [lastNotification, ...s.items] };
});
}
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>
);
}