Notifications and sending songs to friends

Reactions to your songs, new followers, and songs sent to you are now
notifications: a bell on the feed (app and website) with an unread
count that updates live, and a list that marks them read. "Send ✉" on
a song sends it to people who follow you, with an optional note.
Settings picks which kinds to be notified about.

Push delivery uses no third-party accounts:
- Android: UnifiedPush through a distributor app (ntfy), pointed at
  our own ntfy server (docker-compose.ntfy.yml). Works the same in
  the F-Droid build; no Firebase.
- Browsers: standard Web Push from the existing service worker.
Both use Web Push encryption with a VAPID key pair the server makes
and keeps in the database. Push addresses are checked like any other
user-supplied URL. Setup is in docs/PUSH.md; the privacy page covers
the new data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 22:14:34 -04:00
co-authored by Claude Opus 5.5
parent 3b39910f13
commit f112a3515c
55 changed files with 3783 additions and 22 deletions
+60
View File
@@ -0,0 +1,60 @@
'use client';
import Link from 'next/link';
import { useEffect } from 'react';
import { useSWRConfig } from 'swr';
import useSWRInfinite from 'swr/infinite';
import { notificationPath, notificationText, timeAgo, type NotificationListDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { client, useMe } from '@/lib/api';
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
export default function NotificationsPage() {
const { me, loggedOut } = useMe();
const { mutate } = useSWRConfig();
const { data, size, setSize, isLoading } = useSWRInfinite<NotificationListDTO>(
(i, prev: NotificationListDTO | null) => (!me || (i > 0 && !prev?.nextCursor) ? null : ['notifications', prev?.nextCursor ?? null]),
([, cursor]: [string, string | null]) => client.notifications(cursor),
{ revalidateOnFocus: false },
);
const items = data?.flatMap((p) => p.notifications) ?? [];
const unread = data?.[0]?.unread ?? 0;
// Seen once you've looked: clear the bell (rows stay highlighted until you come back).
useEffect(() => {
if (unread) client.markNotificationsRead().then(() => mutate('/notifications'), () => {});
}, [unread, mutate]);
if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your notifications.</p>;
return (
<div className="stack">
<h1>Notifications</h1>
{items.length === 0 && !isLoading && (
<p className="muted">Nothing yet. Reactions, new followers and songs sent to you show up here.</p>
)}
<ul className="notifications">
{items.map((n) => {
const { title, body } = notificationText(n);
return (
<li key={n.id} className={n.read ? '' : 'unread'}>
<Link href={notificationPath(n)} className="notification">
<Avatar user={n.actor} size={40} />
<span className="notification-text">
<strong>{title}</strong>
<span>{body}</span>
<span className="muted">{timeAgo(n.createdAt)}</span>
</span>
{n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />}
</Link>
</li>
);
})}
</ul>
{data?.at(-1)?.nextCursor && (
<button className="btn" onClick={() => setSize(size + 1)}>
Show more
</button>
)}
</div>
);
}