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
+14 -3
View File
@@ -3,9 +3,10 @@ import { useEffect, useState } from 'react';
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr';
import useSWR, { useSWRConfig } from 'swr';
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
import { AmbientBackground } from '@/components/AmbientBackground';
import { ActivityBell } from '@/components/ActivityBell';
import { Avatar } from '@/components/Avatar';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { RelinkReminder } from '@/components/RelinkReminder';
@@ -18,10 +19,11 @@ import { useReader } from '@/lib/nowPlayingReader';
import { shouldOfferSetup } from '@/lib/setup';
import { useRealtime } from '@/lib/useRealtime';
import { useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme';
import { colors, fonts, space } from '@/lib/theme';
export default function FeedScreen() {
const { me } = useSession();
const { mutate: mutateGlobal } = useSWRConfig();
const { data: feed, mutate, isLoading } = useSWR<FeedItemDTO[]>('/feed');
// 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);
@@ -44,7 +46,10 @@ export default function FeedScreen() {
}, []);
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;
@@ -88,6 +93,10 @@ export default function FeedScreen() {
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
ListHeaderComponent={
<View style={styles.header}>
<View style={styles.topBar}>
<Text style={styles.brand}>musicshare</Text>
<ActivityBell />
</View>
<SharingControls compact />
<RelinkReminder />
{offerAppleMusic && (
@@ -170,6 +179,8 @@ const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl * 3 },
header: { gap: space.md, marginBottom: space.xs },
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
brand: { color: colors.text, fontFamily: fonts.display, fontSize: 22, letterSpacing: -0.4 },
sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 },
quiet: { gap: space.lg, paddingVertical: space.xs },
quietPerson: { width: 64, alignItems: 'center', gap: 4 },
+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();
+5
View File
@@ -10,6 +10,7 @@ import { Body, Button, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
import { syncDevice } from '@/lib/nowPlayingReader';
import { syncPush } from '@/lib/push';
import { SessionProvider, useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme';
@@ -45,6 +46,7 @@ function AppNavigator() {
// Keep the Android listener pointed at this account (no-op elsewhere).
useEffect(() => {
if (signedIn) syncDevice().catch(() => {});
if (signedIn) syncPush().catch(() => {});
}, [signedIn]);
if (!ready) return null;
@@ -99,6 +101,9 @@ function AppNavigator() {
<Stack.Screen name="change-password" options={{ title: 'Change password', presentation: 'modal' }} />
<Stack.Screen name="change-email" options={{ title: 'Change email', presentation: 'modal' }} />
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
<Stack.Screen name="t/[id]" options={{ title: '' }} />
<Stack.Screen name="send/[trackId]" options={{ title: 'Send to friends', presentation: 'modal' }} />
</Stack.Protected>
<Stack.Protected guard={!signedIn}>
<Stack.Screen name="login" options={{ headerShown: false }} />
+84
View File
@@ -0,0 +1,84 @@
import { Image } from 'expo-image';
import { router, useFocusEffect } from 'expo-router';
import { useCallback } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSWRConfig } from 'swr';
import useSWRInfinite from 'swr/infinite';
import { notificationPath, notificationText, timeAgo, type NotificationDTO, type NotificationListDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { Body } from '@/components/ui';
import { api } from '@/lib/api';
import { colors, space } from '@/lib/theme';
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
export default function NotificationsScreen() {
const { mutate: mutateGlobal } = useSWRConfig();
const { data, size, setSize, isLoading, mutate } = useSWRInfinite<NotificationListDTO>(
(i, prev: NotificationListDTO | null) => (i > 0 && !prev?.nextCursor ? null : ['notifications', prev?.nextCursor ?? null]),
([, cursor]: [string, string | null]) => api.notifications(cursor),
);
const items = data?.flatMap((p) => p.notifications) ?? [];
const more = Boolean(data?.at(-1)?.nextCursor);
// Seen once you've looked; the bell clears (the list keeps showing which were new until you leave).
const unread = data?.[0]?.unread ?? 0;
useFocusEffect(
useCallback(() => {
if (!unread) return;
api
.markNotificationsRead()
.then(() => mutateGlobal('/notifications'))
.catch(() => {});
return () => {
mutate();
};
}, [unread, mutate, mutateGlobal]),
);
return (
<FlatList
data={items}
keyExtractor={(n) => n.id}
contentContainerStyle={styles.list}
style={{ backgroundColor: colors.bg }}
onEndReached={() => more && setSize(size + 1)}
ListEmptyComponent={
isLoading ? null : <Body muted>Nothing yet. Reactions, new followers and songs sent to you show up here.</Body>
}
renderItem={({ item }) => <Row n={item} />}
/>
);
}
function Row({ n }: { n: NotificationDTO }) {
const { title, body } = notificationText(n);
return (
<Pressable
onPress={() => router.push(notificationPath(n) as never)}
style={({ pressed }) => [styles.row, !n.read && styles.unread, pressed && { opacity: 0.7 }]}
accessibilityRole="link">
<Avatar user={n.actor} size={40} />
<View style={styles.text}>
<Text style={styles.title} numberOfLines={2}>
{title}
</Text>
<Text style={styles.body} numberOfLines={3}>
{body}
</Text>
<Text style={styles.time}>{timeAgo(n.createdAt)}</Text>
</View>
{n.track?.artworkUrl && <Image source={n.track.artworkUrl} style={styles.art} contentFit="cover" />}
</Pressable>
);
}
const styles = StyleSheet.create({
list: { padding: space.lg, gap: space.sm },
row: { flexDirection: 'row', gap: space.md, alignItems: 'center', padding: space.md, borderRadius: 14 },
unread: { backgroundColor: colors.surface2 },
text: { flex: 1, minWidth: 0, gap: 2 },
title: { color: colors.text, fontWeight: '700', fontSize: 15 },
body: { color: colors.text, opacity: 0.85, fontSize: 14 },
time: { color: colors.muted, fontSize: 12 },
art: { width: 44, height: 44, borderRadius: 8 },
});
+107
View File
@@ -0,0 +1,107 @@
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import type { TrackDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { emptyFollowText, useFollowList } from '@/components/FollowList';
import { Body, Button, Field } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { successHaptic, tapHaptic } from '@/lib/haptics';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
/** Send a song to some of your followers, with an optional note. */
export default function SendSongScreen() {
const { trackId } = useLocalSearchParams<{ trackId: string }>();
const { me } = useSession();
const { data: track } = useSWR<TrackDTO>(trackId ? `/tracks/${trackId}` : null);
const followers = useFollowList(me?.username, 'followers');
const [picked, setPicked] = useState<Set<string>>(new Set());
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const toggle = (username: string) => {
tapHaptic();
setPicked((p) => {
const next = new Set(p);
if (next.has(username)) next.delete(username);
else next.add(username);
return next;
});
};
const send = async () => {
setBusy(true);
try {
await api.sendSong({ trackId, to: [...picked], message: message.trim() || null });
successHaptic();
router.back();
} catch (err) {
showError(err, "Couldn't send");
}
setBusy(false);
};
return (
<FlatList
style={{ backgroundColor: colors.bg }}
contentContainerStyle={styles.list}
data={followers.users}
keyExtractor={(u) => u.id}
onEndReached={followers.loadMore}
keyboardShouldPersistTaps="handled"
ListHeaderComponent={
<View style={{ gap: space.md }}>
{track && (
<Text style={styles.song} numberOfLines={2}>
{track.title} <Text style={styles.muted}>· {track.artist}</Text>
</Text>
)}
<Field placeholder="Add a note (optional)" value={message} onChangeText={setMessage} maxLength={280} />
<Body muted style={{ fontSize: 13 }}>
You can send songs to people who follow you.
</Body>
</View>
}
ListEmptyComponent={followers.isLoading ? null : <Body muted>{emptyFollowText('followers')}</Body>}
renderItem={({ item: u }) => {
const on = picked.has(u.username);
return (
<Pressable
onPress={() => toggle(u.username)}
style={[styles.person, on && styles.on]}
accessibilityRole="checkbox"
accessibilityState={{ checked: on }}>
<Avatar user={u} size={36} />
<Text style={styles.name} numberOfLines={1}>
{u.displayName ?? u.username}
</Text>
<Text style={[styles.check, on && { color: colors.text }]}>{on ? '✓' : '○'}</Text>
</Pressable>
);
}}
ListFooterComponent={
<Button
variant="primary"
title={picked.size ? `Send to ${picked.size}` : 'Pick someone'}
disabled={!picked.size}
loading={busy}
onPress={send}
style={{ marginTop: space.md }}
/>
}
/>
);
}
const styles = StyleSheet.create({
list: { padding: space.lg, gap: space.sm, paddingBottom: space.xl * 2 },
song: { color: colors.text, fontSize: 17, fontWeight: '700' },
muted: { color: colors.muted, fontWeight: '400' },
person: { flexDirection: 'row', alignItems: 'center', gap: space.md, padding: space.sm, borderRadius: radius.md },
on: { backgroundColor: colors.surface2 },
name: { flex: 1, color: colors.text, fontSize: 15, fontWeight: '600' },
check: { color: colors.muted, fontSize: 18 },
});
+29
View File
@@ -0,0 +1,29 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import useSWR from 'swr';
import type { TrackDTO } from '@musicshare/shared';
import { Lyrics } from '@/components/Lyrics';
import { Screen } from '@/components/Screen';
import { TrackCard } from '@/components/TrackCard';
import { Body, Card } from '@/components/ui';
import { useSession } from '@/lib/session';
/** One song: where notifications and shared links lead. */
export default function TrackScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { me } = useSession();
const { data: track, error } = useSWR<TrackDTO>(id ? `/tracks/${id}` : null);
return (
<Screen edges={[]}>
<Stack.Screen options={{ title: track?.title ?? '' }} />
{error && <Body muted>That song couldn&apos;t be found.</Body>}
{track && (
<>
<TrackCard track={track} preferred={me?.preferredService} />
<Card>
<Lyrics track={track} />
</Card>
</>
)}
</Screen>
);
}