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
+34 -2
View File
@@ -1,8 +1,8 @@
// musicshare service worker.
// Deliberately small: live data must always come from the network, so we only
// cache Next.js's immutable build assets, and show an offline page when a
// navigation fails. (Web push notifications will hook in here later.)
const VERSION = 'v1';
// navigation fails. It also shows push notifications (reactions, follows, songs sent to you).
const VERSION = 'v2';
const STATIC_CACHE = `static-${VERSION}`;
const OFFLINE_URL = '/offline.html';
@@ -47,3 +47,35 @@ self.addEventListener('fetch', (event) => {
event.respondWith(fetch(request).catch(() => caches.match(OFFLINE_URL)));
}
});
// Push: { id, title, body, path, icon } from the server, already decrypted by the browser.
self.addEventListener('push', (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
return;
}
event.waitUntil(
self.registration.showNotification(data.title || 'musicshare', {
body: data.body || '',
icon: data.icon || '/icon-192.png',
badge: '/icon-192.png',
tag: data.id,
data: { path: data.path || '/' },
}),
);
});
// Tap: focus an open tab and go there, or open a new one.
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = new URL(event.notification.data?.path || '/', self.location.origin).href;
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((tabs) => {
const tab = tabs.find((t) => new URL(t.url).origin === self.location.origin);
if (tab) return tab.focus().then(() => tab.navigate(url));
return self.clients.openWindow(url);
}),
);
});
+19
View File
@@ -79,6 +79,24 @@ input:focus, textarea:focus, select:focus {
.modal { max-width: 26rem; width: calc(100% - 2rem); color: var(--text); background: #15112c; }
.modal::backdrop { background: rgba(5, 3, 15, 0.7); backdrop-filter: blur(4px); }
.modal p { margin-bottom: 1rem; }
.modal .form p { margin: 0; }
.pick-list { max-height: 45vh; overflow-y: auto; }
.pick-list .checkbox { display: flex; align-items: center; gap: 0.6rem; cursor: pointer; font-weight: 500; }
.bell {
position: relative; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 999px; margin-left: auto; margin-right: 1rem;
color: var(--muted); background: rgba(255, 255, 255, 0.06); transition: color 0.2s, background 0.2s;
}
.bell:hover { color: var(--text); background: rgba(255, 255, 255, 0.12); }
.bell-badge {
position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 4px; border-radius: 9px;
background: var(--danger); color: #fff; font-size: 0.68rem; font-weight: 700; display: grid; place-items: center;
}
.notifications { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.notification { display: flex; align-items: center; gap: 0.8rem; padding: 0.7rem 0.8rem; border-radius: 14px; text-decoration: none; color: var(--text); }
.notification:hover { background: rgba(255, 255, 255, 0.05); }
.notifications li.unread .notification { background: var(--surface-2); }
.notification-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; font-size: 0.92rem; }
.notification-text .muted { font-size: 0.78rem; }
.choices { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.45rem; }
.people { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.6rem; }
.people li { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
@@ -174,6 +192,7 @@ input:focus, textarea:focus, select:focus {
@media (max-width: 640px) {
/* Signed in, the tab bar has the links; the top bar is just the logo. */
.nav:has(+ .tabbar) { justify-content: center; }
.nav:has(+ .tabbar) .bell { position: absolute; right: 1rem; margin: 0; }
.nav:has(+ .tabbar) .nav-links { display: none; }
.tabbar {
display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
+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>
);
}
+6 -2
View File
@@ -2,7 +2,7 @@
import { AnimatePresence, motion } from 'motion/react';
import Link from 'next/link';
import useSWR from 'swr';
import useSWR, { useSWRConfig } from 'swr';
import { sortFeed, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { Equalizer } from '@/components/Equalizer';
@@ -20,6 +20,7 @@ export default function Home() {
function Feed() {
const { me } = useMe();
const { mutate: mutateGlobal } = useSWRConfig();
const { data: feed, mutate } = useSWR<FeedItemDTO[]>('/feed', fetcher);
// Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly.
const { data: mine, mutate: mutateMine } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null, fetcher);
@@ -29,7 +30,10 @@ function Feed() {
const quiet = following?.users.filter((u) => !inFeed.has(u.id)) ?? [];
useRealtime(true, (msg) => {
if (msg.type !== 'now_playing') return;
if (msg.type === 'notification') {
mutateGlobal('/notifications');
return;
}
if (msg.data.user.id === me?.id) {
if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false });
return;
+14 -2
View File
@@ -3,7 +3,7 @@ import Link from 'next/link';
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
const UPDATED = 'October 6, 2026';
const UPDATED = 'October 14, 2026';
const CONTACT = 'app@musicshare.binarygnome.com';
export default function PrivacyPage() {
@@ -43,6 +43,11 @@ export default function PrivacyPage() {
<li>
<strong>People:</strong> who you follow, who you&apos;ve pinned, who you&apos;ve blocked, and reports you send.
</li>
<li>
<strong>Notifications:</strong> reactions, new followers and songs (with any note) sent to you, and your
notification settings. If you turn on push notifications, we store the push address your browser or
phone gives us and its encryption keys.
</li>
<li>
<strong>Technical data:</strong> sign-in sessions (stored as hashes), and your IP address, which is used briefly
to prevent abuse (like limiting sign-in attempts) and appears in server logs that are kept for a short time.
@@ -82,7 +87,13 @@ export default function PrivacyPage() {
<li>
<strong>Song matching:</strong> to build &ldquo;open in your app&rdquo; links we look up song titles and artists
with Deezer, Apple&apos;s iTunes search, Spotify&apos;s catalog and Odesli (song.link). These lookups contain only
the song, never who listened to it.
the song, never who listened to it. Lyrics come from LRCLIB, looked up the same way: just the song.
</li>
<li>
<strong>Push notifications:</strong> if you turn them on, notifications are encrypted on our server and
delivered through your browser maker&apos;s push service (for example Google for Chrome, Mozilla for Firefox) or,
on Android, through the push server your ntfy app uses (ours, unless you chose another). They can&apos;t read
the contents.
</li>
<li>
<strong>Our own servers:</strong> the app, database and email run on servers we manage ourselves.
@@ -97,6 +108,7 @@ export default function PrivacyPage() {
<h2>How long we keep it</h2>
<ul>
<li>Recent plays: the latest 50. Older ones are deleted as new ones come in.</li>
<li>Notifications: until you delete your account. Push addresses are deleted when you turn push off or the device stops accepting them.</li>
<li>
Everything else: until you delete it or your account. Deleting your account (Settings &rarr; Delete account)
removes your profile, picture, plays, follows and connected services right away. Database backups are kept for
+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();
+21
View File
@@ -0,0 +1,21 @@
'use client';
import Link from 'next/link';
import useSWR from 'swr';
import type { NotificationListDTO } from '@musicshare/shared';
import { fetcher } from '@/lib/api';
/** Bell with the unread count; opens your notifications. */
export function ActivityBell() {
const { data } = useSWR<NotificationListDTO>('/notifications', fetcher, { refreshInterval: 60_000 });
const unread = data?.unread ?? 0;
return (
<Link href="/notifications" className="bell" aria-label={unread ? `Notifications, ${unread} new` : 'Notifications'}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
{unread > 0 && <span className="bell-badge">{unread > 99 ? '99+' : unread}</span>}
</Link>
);
}
+2
View File
@@ -4,6 +4,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { client, useMe } from '@/lib/api';
import { ActivityBell } from './ActivityBell';
/** Top bar: logo and the four main places. On phones those move to a bottom tab bar. */
export function Nav() {
@@ -25,6 +26,7 @@ export function Nav() {
<img className="brand-logo" src="/logo.png" alt="" width={30} height={30} />
musicshare
</Link>
{me && <ActivityBell />}
<div className="nav-links">
{me ? (
tabs.map((t) => (
+104
View File
@@ -0,0 +1,104 @@
'use client';
import { useRef, useState } from 'react';
import useSWRInfinite from 'swr/infinite';
import type { FollowListDTO, TrackDTO } from '@musicshare/shared';
import { client, useMe } from '@/lib/api';
import { Avatar } from './Avatar';
/** "Send ✉": pick followers and send them the song, with an optional note. */
export function SendSongButton({ track }: { track: TrackDTO }) {
const { me } = useMe();
const dialog = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false);
if (!me) return null;
return (
<>
<button
type="button"
className="service-link share-link"
onClick={() => {
setOpen(true);
dialog.current?.showModal();
}}
title="Send to friends"
>
Send ✉
</button>
<dialog ref={dialog} className="modal card" onClose={() => setOpen(false)} onClick={(e) => e.target === dialog.current && dialog.current?.close()}>
{open && <SendForm track={track} username={me.username} close={() => dialog.current?.close()} />}
</dialog>
</>
);
}
function SendForm({ track, username, close }: { track: TrackDTO; username: string; close: () => void }) {
const { data, size, setSize, isLoading } = useSWRInfinite<FollowListDTO>(
(i, prev: FollowListDTO | null) => (i > 0 && !prev?.nextCursor ? null : ['followers', username, prev?.nextCursor ?? null]),
([, u, cursor]: [string, string, string | null]) => client.followers(u, cursor),
);
const followers = data?.flatMap((p) => p.users) ?? [];
const [picked, setPicked] = useState<Set<string>>(new Set());
const [message, setMessage] = useState('');
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
const [error, setError] = useState<string | null>(null);
const toggle = (u: string) =>
setPicked((p) => {
const next = new Set(p);
if (next.has(u)) next.delete(u);
else next.add(u);
return next;
});
const send = async (e: React.FormEvent) => {
e.preventDefault();
setState('sending');
setError(null);
try {
await client.sendSong({ trackId: track.id, to: [...picked], message: message.trim() || null });
setState('sent');
setTimeout(close, 900);
} catch (err) {
setError((err as Error).message);
setState('idle');
}
};
return (
<form className="form" onSubmit={send}>
<h2>Send to friends</h2>
<p className="muted">
{track.title} · {track.artist}
</p>
<input placeholder="Add a note (optional)" value={message} onChange={(e) => setMessage(e.target.value)} maxLength={280} />
<p className="muted small">You can send songs to people who follow you.</p>
{followers.length === 0 && !isLoading && <p className="muted">No followers yet.</p>}
<ul className="people pick-list">
{followers.map((u) => (
<li key={u.id}>
<label className="checkbox">
<input type="checkbox" checked={picked.has(u.username)} onChange={() => toggle(u.username)} />
<Avatar user={u} size={32} />
<span className="user-name">{u.displayName ?? u.username}</span>
</label>
</li>
))}
</ul>
{data?.at(-1)?.nextCursor && (
<button type="button" className="btn btn-small" onClick={() => setSize(size + 1)}>
Show more
</button>
)}
{error && <p className="error">{error}</p>}
<div className="row">
<button className="btn btn-primary" disabled={!picked.size || state !== 'idle'}>
{state === 'sent' ? 'Sent ✓' : picked.size ? `Send to ${picked.size}` : 'Pick someone'}
</button>
<button type="button" className="btn" onClick={close}>
Cancel
</button>
</div>
</form>
);
}
+2
View File
@@ -2,6 +2,7 @@
import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
import { useMe } from '@/lib/api';
import { SendSongButton } from './SendSong';
import { ShareButton } from './ShareButton';
function Artwork({ track, small }: { track: TrackDTO; small?: boolean }) {
@@ -41,6 +42,7 @@ export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred:
</a>
);
})}
<SendSongButton track={track} />
<ShareButton track={track} />
</>
);
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { client } from './api';
/** Browser push needs a service worker (production builds register /sw.js) and the Push API. */
export const browserPushSupported = () =>
typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
async function registration() {
return (await navigator.serviceWorker.getRegistration('/')) ?? navigator.serviceWorker.register('/sw.js', { scope: '/', updateViaCache: 'none' });
}
/** This browser's push subscription, if any. */
export async function currentSubscription(): Promise<PushSubscription | null> {
if (!browserPushSupported()) return null;
return (await registration()).pushManager.getSubscription();
}
const toBytes = (base64url: string) => {
const raw = atob(base64url.replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
};
/** Ask for permission, subscribe, and tell the server. Returns false if the user said no. */
export async function enableBrowserPush(): Promise<boolean> {
if ((await Notification.requestPermission()) !== 'granted') return false;
const { publicKey } = await client.pushKey();
const reg = await registration();
await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(publicKey) });
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
await client.addPushSubscription({ kind: 'web', endpoint: json.endpoint, keys: json.keys });
return true;
}
export async function disableBrowserPush() {
const sub = await currentSubscription();
if (!sub) return;
await client.removePushSubscription(sub.endpoint).catch(() => {});
await sub.unsubscribe();
}