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);
}),
);
});