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
+74
View File
@@ -11,6 +11,9 @@ import {
type MeDTO,
type NowPlayingProvider,
type StreamingService,
type NotificationType,
NOTIFICATION_PREF_LABELS,
NOTIFICATION_TYPES,
} from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { RelinkReminder } from '@/components/RelinkReminder';
@@ -25,6 +28,7 @@ import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
@@ -36,6 +40,7 @@ export default function SettingsScreen() {
<SharingControls />
<RelinkReminder />
<MyServices />
<Notifications />
<ThisPhone />
<Connections />
<FeaturedPicker />
@@ -161,6 +166,75 @@ function MyServices() {
);
}
/** What to be notified about, and push delivery on this phone. */
function Notifications() {
const { me, setMe } = useSession();
const push = usePushStatus();
const [busy, setBusy] = useState(false);
if (!me) return null;
const toggle = async (type: NotificationType) => {
try {
setMe(await api.updateMe({ notifyPrefs: { [type]: !me.notifyPrefs[type] } }));
} catch (err) {
showError(err);
}
};
const turnOn = async () => {
setBusy(true);
try {
const result = await enablePush();
if (result === 'denied') Alert.alert('Notifications are off', 'Allow notifications for musicshare in Android settings, then try again.');
if (result === 'no-distributor') push.refresh();
} catch (err) {
showError(err);
}
setBusy(false);
};
const turnOff = async () => {
setBusy(true);
await disablePush().catch((err) => showError(err));
push.refresh();
setBusy(false);
};
return (
<Card>
<Heading>Notifications</Heading>
<Body muted style={styles.small}>
Notify me about:
</Body>
<Row>
{NOTIFICATION_TYPES.map((t) => (
<Chip key={t} label={NOTIFICATION_PREF_LABELS[t]} selected={me.notifyPrefs[t]} onPress={() => toggle(t)} />
))}
</Row>
{pushAvailable &&
(push.enabled ? (
<>
<Body muted style={styles.small}>
On for this phone{push.distributor ? `, delivered by ${push.distributor}` : ''}.
</Body>
<Button small title="Turn off on this phone" loading={busy} onPress={turnOff} style={{ alignSelf: 'flex-start' }} />
</>
) : push.hasDistributor ? (
<Button small variant="primary" title="Turn on for this phone" loading={busy} onPress={turnOn} style={{ alignSelf: 'flex-start' }} />
) : (
<>
<Body muted style={styles.small}>
musicshare doesn&apos;t use Google to deliver notifications. Install ntfy (free and open source), then come back and turn
them on. You&apos;ll still see everything under the bell on the feed without it.
</Body>
<Row>
<Button small variant="primary" title="Get ntfy" onPress={() => Linking.openURL(NTFY_URL)} />
<Button small title="I've installed it" loading={busy} onPress={turnOn} />
</Row>
</>
))}
</Card>
);
}
/** Android: share from any music app on this phone (no-op elsewhere). */
function ThisPhone() {
const { available, granted, playback } = useReader();