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