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
+71 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useRef, useState } from 'react';
import { Suspense, useEffect, useRef, useState } from 'react';
import useSWR from 'swr';
import {
SERVICE_LABELS,
@@ -14,6 +14,9 @@ import {
type NowPlayingProvider,
type ProfileDTO,
type StreamingService,
type NotificationType,
NOTIFICATION_PREF_LABELS,
NOTIFICATION_TYPES,
type TrackSearchResultDTO,
type UserSummaryDTO,
} from '@musicshare/shared';
@@ -22,6 +25,7 @@ import { ServiceName } from '@/components/ServiceName';
import { SharingControls } from '@/components/SharingControls';
import { DONATION_URL } from '@/components/SupportLink';
import { api, client, fetcher, uploadFile, useMe } from '@/lib/api';
import { browserPushSupported, currentSubscription, disableBrowserPush, enableBrowserPush } from '@/lib/push';
import { timeAgo } from '@musicshare/shared';
import { useDebounced } from '@/lib/useDebounced';
@@ -43,6 +47,7 @@ function Settings() {
<h1>Settings</h1>
<ProfileForm me={me} />
<SharingControls />
<NotificationSettings me={me} />
<Connections />
<FeaturedPicker />
<AccountCard me={me} />
@@ -503,6 +508,71 @@ function FeaturedPicker() {
type Panel = 'email' | 'password' | 'blocked' | 'delete' | null;
/** Everything about the account itself, as one list of rows; each opens in place. */
/** What to be notified about, and push in this browser. */
function NotificationSettings({ me }: { me: MeDTO }) {
const { mutate } = useMe();
const [push, setPush] = useState<'unsupported' | 'off' | 'on' | 'blocked' | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!browserPushSupported()) return setPush('unsupported');
if (Notification.permission === 'denied') return setPush('blocked');
currentSubscription().then((s) => setPush(s ? 'on' : 'off'), () => setPush('off'));
}, []);
const toggle = async (type: NotificationType) => {
try {
await mutate(await client.updateMe({ notifyPrefs: { [type]: !me.notifyPrefs[type] } }), { revalidate: false });
} catch (err) {
setError((err as Error).message);
}
};
const setBrowser = async (on: boolean) => {
setBusy(true);
setError(null);
try {
if (on) setPush((await enableBrowserPush()) ? 'on' : 'blocked');
else {
await disableBrowserPush();
setPush('off');
}
} catch (err) {
setError((err as Error).message);
}
setBusy(false);
};
return (
<section className="card form">
<h2>Notifications</h2>
<span className="muted">Notify me about:</span>
{NOTIFICATION_TYPES.map((t) => (
<label key={t} className="checkbox">
<input type="checkbox" checked={me.notifyPrefs[t]} onChange={() => toggle(t)} />
{NOTIFICATION_PREF_LABELS[t]}
</label>
))}
{push === 'on' && (
<p className="muted">
On in this browser.{' '}
<button type="button" className="link-button" onClick={() => setBrowser(false)} disabled={busy}>
Turn off
</button>
</p>
)}
{push === 'off' && (
<button type="button" className="btn btn-primary" onClick={() => setBrowser(true)} disabled={busy}>
Turn on in this browser
</button>
)}
{push === 'blocked' && <p className="muted">This browser is blocking notifications from musicshare; allow them in its site settings.</p>}
{push === 'unsupported' && <p className="muted">This browser can&apos;t show notifications. Everything still appears under the bell.</p>}
{error && <p className="error">{error}</p>}
</section>
);
}
function AccountCard({ me }: { me: MeDTO }) {
const { mutate } = useMe();
const router = useRouter();